Compare commits

...

11 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
35 changed files with 7511 additions and 296 deletions

11
.gitignore vendored
View File

@@ -55,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/

23
app.js
View File

@@ -112,6 +112,29 @@ class AppBootHook {
} 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));
}
}
}

View File

@@ -10,9 +10,14 @@ const { sanitizeUploadFileName } = require('../utils/filename');
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
// 🔴 同源 collapse(缺口9 修复):所有文件发送通道统一走 sendFile util
const { sendFile } = require('../utils/sendFile');
// R178 高危操作审计(best-effort,不阻断业务)
const { recordAudit, ACTIONS: AUDIT_ACTIONS } = require('../lib/auditLog');
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
const { requireTestPassword } = require('../lib/passwordPolicy');
// 🔴 R195:下载文件名的「小组编号」前缀与拼接规则唯一 SoT(与落盘目录名同源)。
// 中职/高职大题单个下载与批量 ZIP 包内名**必须**共用 buildTeamDownloadName。
const { buildTeamDownloadName } = require('../lib/team_folder');
/**
* 管理控制器 - AdminController
@@ -163,6 +168,11 @@ class AdminController extends Controller {
const { ctx, service, app } = this;
if (!this._ensureAdmin()) return;
// 🔴 R161:临时文件路径必须在 try **外**声明,才能被 finally 看到。
// 原实现把 `unlinkSync` 写在 `await importFromExcel` 之后、外层 catch 之前
// ⇒ 解析/导入一抛错就跳过清理,永久泄漏 tmp/excel/import_*.xlsx(实测存量 41 个)。
let tempFilePath = null;
try {
// 获取上传的文件流
const stream = await ctx.getFileStream();
@@ -200,8 +210,7 @@ class AdminController extends Controller {
const testFlag = rawTestFlag === '1' || rawTestFlag === 'true' || rawTestFlag === true;
const result = await service.admin.importFromExcel(tempFilePath, { testFlag });
// 清理临时文件
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
// 🔴 R161:清理已移至外层 finally(此处不再单独清理,避免重复与漏清)。
// 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败
const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : '';
@@ -218,6 +227,235 @@ class AdminController extends Controller {
const msg = (err && err.message) ? err.message : '未知错误';
// 注意:前端 onExcelImport 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀
ctx.body = ctx.helper.error(500, msg);
} finally {
// 🔴 R161:三态可观测(removed/missing/skipped),失败 logger.warn 不静默。
// 原 `catch (e) { /* ignore */ }` 完全静默 ⇒ 「已清理」不可观测。
if (tempFilePath) {
ctx.helper.safeCleanup(tempFilePath, { label: 'importContestants-tmp' });
}
}
}
/**
* 指导教师回填(R176)
* POST /api/admin/contestants/backfill-teacher
*
* multipart:file(报名表 xlsx)+ group(可选,限定组别)+ overwrite(可选,默认 1)
*
* 🔴 内存解析,**不落盘**:报名表含身份证号,而 `app/public` 对静态服务开放
* (题目导入曾落 `app/public/tmp/excel`),一旦落盘即可被猜名下载。
* 🔴 只写 contestants.teacher_name,不新建账号、不改队伍、不碰成绩。
*/
async backfillTeachers() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
const MAX_BYTES = 5 * 1024 * 1024;
try {
const stream = await ctx.getFileStream();
if (!stream || !stream.filename) {
ctx.body = ctx.helper.error(400, '请选择报名表文件');
return;
}
const ext = path.extname(stream.filename).toLowerCase();
if (!['.xlsx', '.xls'].includes(ext)) {
ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式的报名表');
return;
}
const chunks = [];
let size = 0;
for await (const chunk of stream) {
size += chunk.length;
if (size > MAX_BYTES) {
ctx.body = ctx.helper.error(400, '文件超过 5MB,请确认上传的是报名表而不是全量明细');
return;
}
chunks.push(chunk);
}
if (size === 0) {
ctx.body = ctx.helper.error(400, '文件内容为空');
return;
}
const fields = stream.fields || {};
const group = fields.group ? String(fields.group) : '';
// 只有显式传 '0'/'false' 才关闭覆盖;缺省 = 覆盖("重导一次"的语义就是取最新)
const overwrite = !(fields.overwrite === '0' || fields.overwrite === 'false');
const result = await service.admin.backfillTeachers(Buffer.concat(chunks), { group, overwrite });
if (!result.ok) {
// 业务性失败(文件不是报名表 / 缺列 / 找不到教师)⇒ code=400 + 纯人话原因。
// ⚠️ helper.error 只接受 (code, message),要带明细必须显式附加(否则第三参数被静默丢弃)
ctx.body = Object.assign(ctx.helper.error(400, result.message), { data: result });
return;
}
ctx.body = ctx.helper.success(result, result.message);
} catch (err) {
// 同上:multipart 白名单拦截时 err.status=400,不要包成 500
if (err && err.status === 400) {
ctx.body = ctx.helper.error(400, '文件格式不被允许:报名表请上传 .xlsx / .xls');
return;
}
ctx.logger.error('指导教师回填失败:', err);
ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '指导教师回填失败');
}
}
/**
* 成绩公示表导出(R176)
* GET /api/admin/export-publicity/:group
*
* 产物 = 大赛参考模板的 xlsx 半成品:数据行已填,获奖等级留空待人工填。
* ⛔ 与 exportExcel 用途不同:那个是内部核查表(含身份证号/手机号),
* 这个不含任何身份证号 / 手机号,可以直接对外公示。
*/
async exportPublicity() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
// `?meta=1` 只回统计、不下载:前端用它在点「下载」前提示
// 「本组 N 人无小组归属」「标题赛项信息为空」这类需要管理员知情的事实。
// ⚠️ 不能靠自定义响应头带这些字段 —— 生产经 X-Accel 时上游自定义头会被丢弃。
const built = await service.admin.buildPublicityXlsx(group);
if (String(ctx.query.meta || '') === '1') {
ctx.body = ctx.helper.success({
fileName: built.fileName,
rowCount: built.rowCount,
testCount: built.testCount,
noTeamCount: built.noTeamCount,
totalContestants: built.totalContestants,
itemName: built.itemName,
itemCode: built.itemCode,
});
return;
}
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
// 中文文件名:content-disposition 会输出 filename*=UTF-8''… 形式,
// nginx 的 X-Accel 只丢弃自定义头、不丢弃 Content-Disposition(实测)
ctx.attachment(built.fileName);
ctx.body = built.buffer;
} catch (err) {
ctx.logger.error('导出成绩公示表失败:', err);
ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '导出成绩公示表失败');
}
}
/**
* 成绩排名表导出(R220)
* GET /api/admin/export-score-rank/:group
*
* 一行 = 一个参赛小组,按小组**客观题分**降序**全量**导出(不截断 80 个),
* 整行按客观题分区间上底色(≥60 绿 / 50–59.99 黄 / <50 红),
* 表尾给三区间小组数统计(全量 + 全量排名前 80)。
*
* ⛔ 与 exportExcel 用途不同:那个是内部核查表(含身份证号 / 手机号);
* 本表只有名次 / 小组编号 / 学校 / 姓名 / 客观题分,可直接给评审看。
* 🔴 R220-C 用户裁定:本表按**客观题分**筛选 ⇒ 只出 6 列,「大题分」「总分」不出现;
* 排序键 / 并列判据 / 区间底色三者同源,均为客观题分。
* ⚠️ 客观题分仍走「实时口径」(与成绩管理页面同源),⛔ 不读 contestants.score。
*/
async exportScoreRank() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
// 第二参数 false = 只允许考试组(中职 / 高职)
if (!this._validateGroup(group, false)) return;
const built = await service.admin.buildScoreRankXlsx(group);
// `?meta=1` 只回统计、不下载:前端在点「导出」前用它提示
// 「本组 N 人无小组归属」「底色生成失败已降级」这类需要管理员知情的事实。
// ⚠️ 不能靠自定义响应头带这些字段 —— 生产经 X-Accel 时上游自定义头会被丢弃。
if (String(ctx.query.meta || '') === '1') {
ctx.body = ctx.helper.success({
fileName: built.fileName,
rowCount: built.rowCount,
colored: built.colored,
degradeReason: built.degradeReason,
stats: built.stats,
topN: built.topN,
totalContestants: built.totalContestants,
noTeamCount: built.noTeamCount,
});
return;
}
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
// 中文文件名:content-disposition 会输出 filename*=UTF-8''… 形式,
// nginx 的 X-Accel 只丢弃自定义头、不丢弃 Content-Disposition(实测)
ctx.attachment(built.fileName);
ctx.body = built.buffer;
} catch (err) {
ctx.logger.error('导出成绩排名表失败:', err);
if (err && err.bizCode === 'BAD_GROUP') {
ctx.body = ctx.helper.error(400, err.message);
return;
}
ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '导出成绩排名表失败');
}
}
// ==================== 赛项信息配置(R176) ====================
/**
* 读取赛项信息
* GET /api/admin/competition-item
*
* 成绩公示表标题的第 2、3 行要用(赛项名称 / 赛项编号)。
* 用户裁定:不做硬编码,改为两项配置由管理人员在后台填写。
*/
async competitionItemInfo() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const data = await service.admin.getCompetitionItemInfo();
ctx.body = ctx.helper.success(data);
} catch (err) {
ctx.logger.error('读取赛项信息失败:', err);
ctx.body = ctx.helper.error(500, '读取赛项信息失败');
}
}
/**
* 保存赛项信息
* PUT /api/admin/competition-item
* body: { itemName?, itemCode? }
*
* ⚠️ 破坏面:覆盖 competition_info 里两个键的值。只会影响公示表标题文案,
* 不影响成绩、账号、队伍;改错重填即可。
*/
async updateCompetitionItemInfo() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const body = ctx.request.body || {};
// 长度兜底:两个值都会写进 xlsx 标题单元格,过长会把标题撑爆
for (const [k, limit] of [['itemName', 120], ['itemCode', 60]]) {
const v = body[k];
if (v !== undefined && v !== null && String(v).length > limit) {
ctx.body = ctx.helper.error(400, `${k === 'itemName' ? '赛项名称' : '赛项编号'}不能超过 ${limit} 字`);
return;
}
}
const saved = await service.admin.setCompetitionItemInfo({
itemName: body.itemName,
itemCode: body.itemCode,
});
ctx.body = ctx.helper.success(saved, '赛项信息已保存');
} catch (err) {
ctx.logger.error('保存赛项信息失败:', err);
ctx.body = ctx.helper.error(500, '保存赛项信息失败');
}
}
@@ -239,9 +477,12 @@ class AdminController extends Controller {
if (!this._validateGroup(group, true)) return;
// 中职/高职组使用简化版查询(contestants + exam_submissions)
// R195:默认只返回「参赛过的人」(有作答 / 有大题文件 / 已交卷);
// `?includeIdle=1` 才返回全部报名者(前端「显示未参赛」开关)。
const isExamGroup = group === 'zhongzhi' || group === 'gaozhi';
const includeIdle = ctx.query.includeIdle === '1' || ctx.query.includeIdle === 'true';
const submissions = isExamGroup
? await service.admin.getExamSubmissions(group)
? await service.admin.getExamSubmissions(group, { includeIdle })
: await service.admin.getSubmissions(group);
ctx.body = ctx.helper.success({
group,
@@ -338,7 +579,7 @@ class AdminController extends Controller {
* 文件重命名为 "组号-学校-姓名-原文件名" 格式,流式下载 ZIP
*/
async batchDownload() {
const { ctx, service } = this;
const { ctx, service, app } = this;
if (!this._ensureAdmin()) return;
try {
@@ -354,10 +595,31 @@ class AdminController extends Controller {
await sendFile(ctx, this.app, { absolutePath: zipFilePath, fileName, contentType: 'application/zip', useXAccel: false });
// 下载完成后清理 ZIP 文件(Node 流模式下 res.finish 在流发完后触发)
// 🔴 R161:原实现只删 zipFilePath,**漏删 batchDownloadZip 建的 zipDir 工作目录**
// (tmp/zip/<group>/<ts>/*_chunk.pdf,实测残留 340 文件 / 4 目录 ⇒ 每次导出必泄漏)。
// 改用 safeCleanup(三态可观测,失败 logger.warn 不静默)并补删工作目录。
ctx.res.on('finish', () => {
try { fs.unlinkSync(zipFilePath); } catch (e) { /* ignore */ }
ctx.helper.safeCleanup(zipFilePath, { label: 'batchDownload-zipFile' });
// 🔴 R161:清工作目录。zipDir 形如 `tmp/zip/<group>/<ts>/`,zipFile 形如
// `tmp/zip/<group>_submissions_<ts>.zip` ⇒ 二者**无派生关系**,不能靠去扩展名反解。
// ⛔ 不改 service 返回契约(会波及调用方);改为从 `tmp/zip` 根取出 group 后删其目录。
// 删整个 group 目录是安全的:该目录**只**存本方法的 Date.now() 工作目录,
// 不含其他业务文件(works/answers/materials 等均在 tmp 之外)。
const zipRoot = path.join(app.baseDir, 'app/public/tmp/zip');
ctx.helper.safeCleanup(path.join(zipRoot, group), { label: 'batchDownload-zipDir' });
});
} catch (err) {
// 🔴 R195 修既有缺陷:「该组一个已上传文件都没有」是**正常业务态**,不是服务故障。
// `service.admin.batchDownloadZip` 为此专门抛 `bizCode:'EMPTY'`,但这里原先没有分支
// 接住它 ⇒ 落到兜底 500「批量下载失败」,管理端完全看不出真实原因,
// 且每点一次下载就刷一条 ERROR 日志(噪音淹没真故障)。
// ⚠️ 不能用 HTTP 4xx:本项目约定 `helper.error()` 不改状态码,业务错 = 200 + code≠0。
if (err && err.bizCode === 'EMPTY') {
ctx.logger.warn(`[batchDownload] 组 ${ctx.params.group} 暂无已上传文件(业务 404)`);
ctx.body = ctx.helper.error(404, err.message || '该组暂无已上传的作品文件');
return;
}
ctx.logger.error('批量下载失败:', err);
// 如果 archiver 不可用,返回友好错误
@@ -370,8 +632,60 @@ class AdminController extends Controller {
}
}
// ==================== 时间管理 ====================
// ==================== 单小组作品压缩包(R190) ====================
/**
* 下载「单个小组」的作品压缩包
* GET /api/admin/team-zip/:group?teamUid=xx 或 ?aggregationUid=xx
*
* 【业务来源】「管理人员下载作品时将这个文件夹进行压缩,直接下载压缩包」
* 包内**平铺**该小组的文件(zip 本身即小组目录),文件名保留下传时的原名。
*
* @returns {Stream} zip 流
*/
async teamZip() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
// 只有本科/教师组有作品文件(中职/高职组走答题答案通道)
if (!['benke', 'jiaoshi'].includes(group)) {
ctx.body = ctx.helper.error(400, '该组别无作品文件,仅本科组/教师组支持');
return;
}
const zipInfo = await service.admin.teamZip(group, {
teamUid: ctx.query.teamUid,
aggregationUid: ctx.query.aggregationUid,
});
await sendFile(ctx, this.app, {
absolutePath: zipInfo.zipFilePath,
fileName: zipInfo.downloadName,
contentType: 'application/zip',
useXAccel: false,
});
// 下载完成后清理临时 zip(Node 流模式下 res.finish 在流发完后触发)
ctx.res.on('finish', () => {
ctx.helper.safeCleanup(zipInfo.zipFilePath, { label: 'teamZip-zipFile' });
});
} catch (err) {
if (err.bizCode === 'EMPTY') {
ctx.body = ctx.helper.error(404, '该小组暂无已上传的作品文件');
return;
}
if (err.bizCode === 'NOT_FOUND') {
ctx.body = ctx.helper.error(404, err.message || '未找到该小组');
return;
}
ctx.logger.error('小组作品打包失败:', err);
ctx.body = ctx.helper.error(500, '小组作品打包失败');
}
}
// ==================== 时间管理 ====================
/**
* 批量设置比赛时间
* PUT /api/admin/times
@@ -403,9 +717,19 @@ class AdminController extends Controller {
}
}
await service.admin.setTimes(times);
// B② 乐观锁:客户端可回传它读到的旧值(expectedTimes);与库里现值不一致
// 即判冲突,避免后保存者静默覆盖先保存者的结果。不传 ⇒ 退回旧行为。
const expectedTimes = (ctx.request.body && ctx.request.body.expectedTimes) || null;
await service.admin.setTimes(times, { expected: expectedTimes });
ctx.body = ctx.helper.success(null, '比赛时间设置成功');
} catch (err) {
// 🔴 并发冲突是业务性保护,不是 500:必须「200 + 业务码」。
// 按 500 返回会让前端拦截器提示「服务器错误」,掩盖真实原因。
if (err && err.name === 'TimesConflictError') {
ctx.logger.warn('[setTimes] 时间配置并发冲突: %j', err.conflicts);
ctx.body = ctx.helper.error(4090, '比赛时间已被他人修改,请刷新后重试');
return;
}
ctx.logger.error('设置比赛时间失败:', err);
ctx.body = ctx.helper.error(500, '设置比赛时间失败');
}
@@ -428,6 +752,56 @@ class AdminController extends Controller {
}
}
/**
* 节点备份调度器状态
* GET /api/admin/node-backup/status
*
* P1「生产备份静默失败防护」的可见性出口:调度器此前只在 logger 里喊失败,
* 管理后台看不到 ⇒ 直到需要恢复时才发现备份不存在。本接口把
* **待触发计划 / 最近台账 / 备份产物 / 健康判定** 一次性暴露给管理页。
*
* ⛔ 只读接口:不排程、不触发备份、不写任何文件(读台账与目录而已)。
* ⛔ 返回体不含任何凭据:envFile 只给路径,绝不回显其内容(内含 DB 口令)。
*/
async getNodeBackupStatus() {
const { ctx } = this;
if (!this._ensureAdmin()) return;
try {
const scheduler = this.app.nodeBackupScheduler;
const isProduction = !!(this.app.config && this.app.config.isProduction);
if (!scheduler) {
// 🔴 最隐蔽的一种「静默失败」:调度器压根没构造成功(app.js 里被 try/catch 吞了)。
// 此时其余一切正常,只是备份永远不会执行 ⇒ 生产环境必须报红。
ctx.body = ctx.helper.success({
available: false,
enabled: false,
isProduction,
plans: [],
history: [],
artifacts: [],
health: {
level: isProduction ? 'error' : 'ok',
reasons: [{
level: isProduction ? 'error' : 'warn',
code: 'SCHEDULER_MISSING',
message: '节点备份调度器未初始化(app.js didReady 构造失败)⇒ 三节点备份不会执行,请查看应用启动日志',
}],
stats: { consecutiveFails: 0, missedCount: 0, artifactCount: 0, planCount: 0, historyCount: 0 },
},
});
return;
}
const status = scheduler.status(50);
ctx.body = ctx.helper.success(Object.assign({ available: true }, status));
} catch (err) {
ctx.logger.error('获取节点备份状态失败:', err);
ctx.body = ctx.helper.error(500, '获取节点备份状态失败');
}
}
// ==================== 评分管理 ====================
/**
@@ -494,7 +868,10 @@ class AdminController extends Controller {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
const scores = await service.admin.getScores(group);
// R195:默认隐藏「从未参赛」的报名者(中职/高职按小组参赛,未上场组员全 0 分占行);
// `?includeIdle=1` 返回全量(前端「显示未参赛」开关)。
const includeIdle = ctx.query.includeIdle === '1' || ctx.query.includeIdle === 'true';
const scores = await service.admin.getScores(group, { includeIdle });
ctx.body = ctx.helper.success({ list: scores });
} catch (err) {
ctx.logger.error('获取评分列表失败:', err);
@@ -623,6 +1000,9 @@ class AdminController extends Controller {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 🔴 R161:须在 try 外声明,finally 才可见(原来没有 finally ⇒ 抛错即泄漏)。
let tempFilePath = null;
try {
const stream = await ctx.getFileStream();
if (!stream) {
@@ -639,7 +1019,7 @@ class AdminController extends Controller {
const tempDir = path.join(app.baseDir, 'app/public/tmp/excel');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const tempFilePath = path.join(tempDir, `questions_import_${Date.now()}${ext}`);
tempFilePath = path.join(tempDir, `questions_import_${Date.now()}${ext}`);
const writeStream = fs.createWriteStream(tempFilePath);
await new Promise((resolve, reject) => {
@@ -651,14 +1031,19 @@ class AdminController extends Controller {
const result = await service.admin.importQuestionsFromExcel(tempFilePath, { group });
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`);
} catch (err) {
ctx.logger.error('客观题 Excel 导入失败:', err);
const msg = (err && err.message) ? err.message : '未知错误';
// 前端 onExcelFileChange 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀
ctx.body = ctx.helper.error(500, msg);
} finally {
// 🔴 R161:原实现把 unlink 写在 `await importQuestionsFromExcel` 之后、catch 之前
// ⇒ 解析/导入一抛错即永久泄漏 tmp/excel/questions_import_*.xlsx。
// 移入 finally + 改 safeCleanup(三态可观测,失败 logger.warn 不静默)。
if (tempFilePath) {
ctx.helper.safeCleanup(tempFilePath, { label: 'importQuestions-tmp' });
}
}
}
@@ -781,6 +1166,15 @@ class AdminController extends Controller {
await service.admin.publishScores(publish, group);
// R178 审计:发布/撤销成绩会直接改变「考生能不能看到分数」,
// 且发布还会自动生成公示草稿 —— 属于必须能追溯到人的动作。
await recordAudit(
ctx,
publish ? AUDIT_ACTIONS.SCORE_PUBLISH : AUDIT_ACTIONS.SCORE_UNPUBLISH,
group || 'all',
{ group: group || null, scope: group ? 'single' : 'all' }
);
const scopeLabel = group ? '该组成绩' : '全部组成绩';
ctx.body = ctx.helper.success(
{ published: publish, group: group || null },
@@ -1132,6 +1526,93 @@ class AdminController extends Controller {
}
}
/**
* 上传新闻附件(R176)
* POST /api/admin/upload/news-file
*
* 用户裁定:成绩公示改为「新闻附件」形态,所以新闻要支持文件下载,
* 而不是把下载链接手写进正文(正文一旦人工编辑,链接极易被删掉)。
*
* 与 uploadNewsImage 复用同一目录(`app/public/news`)与同一访问路径前缀,
* 区别只在扩展名白名单与大小上限。
*
* @returns {{fileUrl: string, fileName: string, size: number, filename: string}}
*/
async uploadNewsFile() {
const { ctx } = this;
if (!this._ensureAdmin()) return;
const ALLOWED_EXT = ['.xlsx', '.xls', '.pdf', '.docx', '.doc', '.zip'];
const MAX_BYTES = 20 * 1024 * 1024;
try {
const stream = await ctx.getFileStream();
if (!stream || !stream.filename) {
ctx.body = ctx.helper.error(400, '请选择要上传的附件');
return;
}
const ext = path.extname(stream.filename).toLowerCase();
if (ALLOWED_EXT.indexOf(ext) < 0) {
ctx.body = ctx.helper.error(400, `仅支持 ${ALLOWED_EXT.join(' / ')} 格式的附件`);
return;
}
const chunks = [];
let size = 0;
for await (const chunk of stream) {
size += chunk.length;
if (size > MAX_BYTES) {
ctx.body = ctx.helper.error(400, '附件超过 20MB,请压缩后再上传');
return;
}
chunks.push(chunk);
}
if (size === 0) {
ctx.body = ctx.helper.error(400, '附件内容为空');
return;
}
const fs = require('fs');
const uploadDir = path.join(this.app.baseDir, 'app/public/news');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 存盘文件名与展示名分离:存盘用时间戳(避免覆盖与路径穿越),
// 展示名保留原始中文名,写进 news.attachment_name 供下载按钮显示。
const storedName = `news_file_${Date.now()}${ext}`;
fs.writeFileSync(path.join(uploadDir, storedName), Buffer.concat(chunks));
// 🔴 multipart 的 filename 常按 latin1 解出(中文变乱码)。
// 启发式:按 latin1 取回字节后若能完整 UTF-8 解码且含非 ASCII,就用解码结果。
let displayName = String(stream.filename);
const asUtf8 = Buffer.from(displayName, 'latin1').toString('utf8');
if (asUtf8.indexOf('\uFFFD') < 0 && /[^\x00-\x7F]/.test(asUtf8)) {
displayName = asUtf8;
}
// 去掉可能带进来的路径部分(Windows 上传会带 C:\fakepath\)
displayName = displayName.replace(/^.*[\\/]/, '').slice(0, 200);
ctx.body = ctx.helper.success({
fileUrl: `/public/news/${storedName}`,
fileName: displayName,
size,
filename: storedName,
}, '附件上传成功');
} catch (err) {
// 🔴 multipart 层(egg-multipart 的 whitelist)会先于本方法抛 400,
// 统一转成人话;否则前端看到的是一句「上传新闻附件失败」+ 500,
// 管理员无从知道是「格式不支持」。
if (err && err.status === 400) {
ctx.body = ctx.helper.error(400, `文件格式不被允许,仅支持 ${ALLOWED_EXT.join(' / ')}`);
return;
}
ctx.logger.error('上传新闻附件失败:', err);
ctx.body = ctx.helper.error(500, '上传新闻附件失败');
}
}
/**
* 下载考生答案 Word 文件
* GET /api/admin/submissions/:group/:id/word
@@ -1196,8 +1677,23 @@ class AdminController extends Controller {
return;
}
const fileName = wordFileName || path.basename(filePath);
filePathCache.setFull(wordCacheKey, wordFilePath, wordFileName);
let fileName = wordFileName || path.basename(filePath);
if (isExamGroup) {
// 🔴 R195:中职/高职大题下载名 = `<小组编号>-<姓名>-<原文件名>`
// 小组编号口径走 resolveTeamLabels(teamNumber → 未编组_<uid>),
// 与「选手管理 / 提交记录」列表展示、作品落盘目录**同源**,⛔ 不取 contestants.team_id。
// 缓存里存的是**最终名**,故命中缓存时无需再查库。
const teamFolders = await service.admin.getTeamFolders(group, [id]);
const tf = teamFolders.get(String(id)) || {};
fileName = buildTeamDownloadName({
teamNumber: tf.teamNumber,
teamUid: tf.teamUid,
aggregationUid: id,
personName: tf.name,
originalName: fileName,
});
}
filePathCache.setFull(wordCacheKey, wordFilePath, fileName);
// 🔴 X-Accel-Redirect 根治:生产经 nginx 内部重定向吐文件,开发回退流式。
// 文件名编码与路径穿越防护、文件 IO 并发护栏统一在 _sendFile 内处理(见其注释)。
await this._sendFile(
@@ -1771,6 +2267,16 @@ class AdminController extends Controller {
results.jiaoshiSubmissionsDeleted = jiaoshiDeleted;
ctx.logger.info('[测试数据重置]', results);
// R178 审计:这是本系统唯一的「真实批量删除」入口(并会连带复位发布态),
// 必须记下是谁、删了多少行 —— 事后追查「数据怎么没了」只能靠这一条。
await recordAudit(ctx, AUDIT_ACTIONS.DATA_RESET_TEST, 'test-data', {
contestants: contestantCount,
examSubmissionsDeleted: examDeleted,
workSubmissionsDeleted: workDeleted,
orphanFilesRemoved,
});
ctx.body = ctx.helper.success(results, `测试数据已重置:${contestantCount} 名选手,${examDeleted + workDeleted} 条提交记录。已恢复为「未激活 + 默认口令 ${testPw}」,需先用默认口令完成短信激活即可登录重测`);
} catch (err) {
ctx.logger.error('重置测试数据失败:', err);
@@ -1900,7 +2406,10 @@ class AdminController extends Controller {
ctx.body = ctx.helper.error(500, '导入失败:' + msg);
} finally {
// 无论成功/失败都清临时文件:题目内容属敏感数据,不留副本
if (tempFilePath) { try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ } }
// 🔴 R161:finally 位置本就正确,仅把静默 unlinkSync 换成 safeCleanup(三态可观测)
if (tempFilePath) {
ctx.helper.safeCleanup(tempFilePath, { label: 'importBigQuestions-tmp' });
}
}
}
@@ -1982,6 +2491,162 @@ class AdminController extends Controller {
}
}
// ==================== 大题成绩 Excel 导入(R172) ====================
//
// 流程:下载模板 → 上传并**预览**(零写入)→ 确认后**导入**(写入走唯一评分路径)。
// 两段式是刻意的:成绩误写不可撤销,先让管理员看到每一行的判定结果再落库。
/**
* 把上传的 Excel 读进内存 Buffer(⛔ 不落盘)。
*
* 为什么不落盘:
* ① 成绩表含考生成绩(敏感),既有题目导入落在 `app/public/tmp/excel`,
* 而 `app/public` 由 egg-static 对外暴露 ⇒ 文件名可猜即可下载;
* ② 不落盘就不存在「finally 忘了清理」留下的孤儿文件(R161 那类问题的根因)。
* 体积上限 2MB 足够(成绩表通常几十行)。
*
* @param {number} maxBytes
* @returns {Promise<Buffer|null>} 失败时已写好响应体,返回 null
*/
async _readExcelBuffer(maxBytes = 2 * 1024 * 1024) {
const { ctx } = this;
let stream;
try {
stream = await ctx.getFileStream();
} catch (e) {
ctx.body = ctx.helper.error(400, '未接收到文件(需 multipart/form-data,字段名 file)');
return null;
}
const ext = path.extname(stream.filename || '').toLowerCase();
if (!['.xlsx', '.xls'].includes(ext)) {
// 必须把流消费掉,否则连接会悬挂
stream.resume();
ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式');
return null;
}
const chunks = [];
let size = 0;
try {
for await (const chunk of stream) {
size += chunk.length;
if (size > maxBytes) {
stream.resume();
ctx.body = ctx.helper.error(400, `文件过大(上限 ${Math.floor(maxBytes / 1024 / 1024)} MB)`);
return null;
}
chunks.push(chunk);
}
} catch (e) {
ctx.body = ctx.helper.error(400, '读取上传文件失败:' + (e && e.message ? e.message : '未知错误'));
return null;
}
if (!size) {
ctx.body = ctx.helper.error(400, '上传文件为空');
return null;
}
return Buffer.concat(chunks, size);
}
/**
* 下载「综合大题成绩导入」模板
* GET /api/admin/big-answers/:group/import-template
*/
async downloadBigScoreTemplate() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
if (!service.bigQuestion.isEnabledGroup(group)) {
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
return;
}
const buffer = await service.bigQuestion.buildScoreTemplate(group);
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
ctx.attachment(`综合大题成绩导入模板_${group}.xlsx`);
ctx.body = buffer;
} catch (err) {
ctx.logger.error('下载大题成绩导入模板失败:', err);
ctx.body = ctx.helper.error(500, '生成模板失败');
}
}
/**
* 预览成绩导入(⛔ 零写入)
* POST /api/admin/big-answers/:group/import-preview
* multipart/form-data: file
*/
async previewBigScoreImport() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const buffer = await this._readExcelBuffer();
if (!buffer) return;
const result = await service.bigQuestion.previewScoreImport(group, buffer);
if (!result.ok) {
ctx.body = ctx.helper.error(400, result.message);
return;
}
ctx.body = ctx.helper.success(
result,
`预览完成:可导入 ${result.importable} 行,将覆盖 ${result.overwritable} 行,被拒绝 ${result.blocked} 行`
);
} catch (err) {
ctx.logger.error('大题成绩导入预览失败:', err);
ctx.body = ctx.helper.error(500, '预览失败:' + (err && err.message ? err.message : '未知错误'));
}
}
/**
* 执行成绩导入
* POST /api/admin/big-answers/:group/import-scores?overwrite=1
* multipart/form-data: file
*/
async importBigScores() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const buffer = await this._readExcelBuffer();
if (!buffer) return;
const overwrite = ctx.query.overwrite === '1' || ctx.query.overwrite === 'true';
const result = await service.bigQuestion.importScores(group, buffer, { overwrite });
if (!result.ok) {
ctx.body = ctx.helper.error(400, result.message);
return;
}
// preview 明细过大时不必每次回传全部行;这里保留前 200 条错误供前端展示
const errors = (result.errors || []).slice(0, 200);
ctx.body = ctx.helper.success({
imported: result.imported,
skipped: result.skipped,
overwritten: result.overwritten,
errors,
errorTotal: (result.errors || []).length,
untruncated: (result.errors || []).length <= 200,
}, `导入完成:成功 ${result.imported} 行(其中覆盖 ${result.overwritten} 行),跳过 ${result.skipped} 行`);
} catch (err) {
ctx.logger.error('大题成绩导入失败:', err);
ctx.body = ctx.helper.error(500, '导入失败:' + (err && err.message ? err.message : '未知错误'));
}
}
/**
* 新增大题
* POST /api/admin/big-questions/:group

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

@@ -6,6 +6,8 @@ const VALIDATION_RULES = require('../validate');
// 口令强度策略(唯一事实来源):黑名单 + 纯重复/序列 + 账号派生值 + 长度上下限。
// ⚠️ 任何新增的口令写入入口都必须调用它,禁止在控制器内自行判断(见 lib/passwordPolicy.js 模块头)。
const passwordPolicy = require('../lib/passwordPolicy');
// R178:管理端权限点解析(与 middleware/auth.js 同一真相源)
const adminPermissions = require('../lib/adminPermissions');
/**
* 认证控制器 - AuthController
@@ -372,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} 在生产环境尝试使用默认密码登录,已拒绝`);
@@ -393,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,

View File

@@ -389,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 },
@@ -452,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, '提交答案失败');
}

View File

@@ -48,7 +48,10 @@ 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;
@@ -107,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) {
@@ -132,15 +154,18 @@ class UploadController extends Controller {
return;
}
// 保存文件
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group);
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
// 获取作品名称(流式模式字段在 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,
@@ -191,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;
@@ -265,6 +292,8 @@ class UploadController extends Controller {
const { ctx, service, app } = this;
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
let mergedRelPath = null;
// 合并临时文件(先写临时名再原子 rename)。失败时需清掉,否则留下 `.merge_*` 残渣。
let mergedTmpPath = null;
let recordPersisted = false;
try {
@@ -291,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)) {
@@ -310,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/事件循环耗尽雪崩。
@@ -332,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,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会
@@ -341,17 +390,11 @@ class UploadController extends Controller {
// 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。
ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' });
// 构建相对路径
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
mergedRelPath = relativePath; // 供 catch 回滚孤儿文件用
// 原子落位 + 计算 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,
@@ -365,11 +408,11 @@ class UploadController extends Controller {
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 {
@@ -380,6 +423,10 @@ class UploadController extends Controller {
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, '分片合并失败');
}
@@ -389,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;
@@ -444,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);

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

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

View File

@@ -159,7 +159,8 @@ function firstLineTwips(node) {
* 深度优先遍历,产出「块」序列:
* { type:'p', runs:[...], align, firstLine }
* { type:'table', rows:[{ cells:[{ blocks:[...] }] }] }
* run 形态:{ text, bold, italic } 或 { br: true }
* run 形态:{ text, bold, italic, size, color } 或 { br: true }
* color 为 6 位十六进制(可带 `#` 前缀),如 'FF0000' / '#FF0000';非法值忽略。
*/
function walk(nodes, fmt, out) {
const flush = () => {
@@ -274,11 +275,31 @@ function textToBlocks(text) {
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>`;
@@ -464,6 +485,6 @@ module.exports = {
textToBlocks,
decodeEntities,
esc,
// 导出内部件便于单测
_internal: { parseHtml, buildDocumentXml, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS },
// 导出内部件便于单测(`scripts/verify_big_score_import.py` 会断言 color 渲染与序列位置)
_internal: { parseHtml, buildDocumentXml, rPrOf, normalizeColor, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS },
};

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

View File

@@ -24,6 +24,10 @@
* · `app/controller/auth.js` → `activate()` (短信激活,设置初始口令)
* · `app/controller/auth.js` → `resetPassword()` (忘记密码,重设口令)
*
* 另有一项**非强度**职责也收在本模块(保持「管理员默认凭据」口径单源,见文件末段):
* · `app/controller/auth.js` → `adminLogin()` 的「默认口令守卫」读取本模块的
* `DEFAULT_ADMIN_PASSWORDS` / `isDefaultAdminHash()` —— R112 · 候选㉒ 下沉。
*
* 不适用范围(重要,勿误改)
* ------------------------------------------------------------
* 以下链路**直写数据库、不经过本策略**,是**设计内**行为,不是绕过:
@@ -220,6 +224,96 @@ function requireTestPassword(appConfig) {
);
}
/**
* ── 内置默认管理员凭据(登录守卫的唯一真相源)──────────────────────
*
* 背景(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,
@@ -228,4 +322,8 @@ module.exports = {
STRENGTH_HINT,
// 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword()
BLACKLIST,
// 默认管理员凭据判据(登录守卫用;见上文「内置默认管理员凭据」段)
DEFAULT_ADMIN_PASSWORDS,
KNOWN_DEFAULT_ADMIN_HASHES,
isDefaultAdminHash,
};

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

@@ -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

@@ -133,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

@@ -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

@@ -74,6 +74,16 @@ module.exports = app => {
word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 2 位小数)'),
// PPT分数(本科/教师组管理员手动打分)
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: '指导教师姓名(同组多人用、分隔)',
},
// 所属团队 ID(外键关联 teams 表)
team_id: {
type: INTEGER,

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

@@ -110,6 +110,19 @@ 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);
@@ -119,6 +132,12 @@ module.exports = app => {
// ⚠️ 路径段固定、无通配,与既有路由无匹配冲突。
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 段内唯一。
@@ -132,13 +151,34 @@ module.exports = app => {
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);
@@ -169,6 +209,12 @@ module.exports = app => {
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);
// 备赛资料管理
@@ -196,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);

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

@@ -7,6 +7,11 @@ const Service = require('egg').Service;
// `@e965/xlsx`(0.20.3,含两处修复,零依赖,API 与 xlsx 完全一致)。
const XLSX = require('@e965/xlsx');
// 成绩导入的**纯判定层**(R172 建 / R174 改三小问四列制):小问取值校验 / 逐行判定 / 表头别名解析。
// ⚠️ 必须走这个 lib,不得在本文件内联第二套算法 —— 否则 `--self-test` 自证的是副本,
// 主流程仍可能是错的(恒绿假自证,R167 M1 血证)。
const bigScoreImport = require('../lib/bigScoreImport');
/**
* 综合大题服务 - BigQuestionService
*
@@ -337,15 +342,24 @@ class BigQuestionService extends Service {
order: [['id', 'ASC']],
});
// R172:补「小组编号」。小组身份键 = (组别 group, 队伍号 teamUid),
// ⛔ 绝不可单用 team_uid —— 运行库中不同组会复用同一数值(如 benke 与 zhongzhi 都有 team_uid=1)。
// 解析优先级与 admin.getContestants 同源:aggregation.team_uid(报名聚合,权威)→ contestants.team_id(兜底)。
const teamMeta = await this._loadTeamMeta(group, contestants.map(c => c.id));
return contestants.map(c => {
const rec = byContestant[String(c.id)] || byAgg[String(c.id)] || null;
const tm = teamMeta.byContestant[String(c.id)] || null;
return {
contestantId: c.id,
aggregationUid: rec ? rec.aggregation_uid : c.id,
name: c.name,
idNumber: c.id_number,
phone: c.phone,
// teamId 保留为**整数队伍号**(历史字段,勿改语义);teamNumber 才是人类可读的小组编号
teamId: c.team_id,
teamUid: tm ? tm.teamUid : null,
teamNumber: tm ? tm.teamNumber : null,
drawn: !!rec,
bigQuestionUid: rec ? rec.big_question_uid : null,
questionSnapshot: rec ? this.parseSnapshot(rec.question_snapshot) : null,
@@ -363,6 +377,74 @@ class BigQuestionService extends Service {
});
}
/**
* 批量解析「考生 → 小组编号」(R172)。
*
* 小组身份键 = (group, team_uid)。⛔ 不可只用 team_uid:不同组会复用同一数值,
* 单键解析会把别组的小组编号贴到本组考生身上(与铁律「team_uid 仅组内唯一」同源)。
*
* 来源优先级:
* ① `aggregation.team_uid`(报名表聚合写入,权威)
* ② `contestants.team_id`(导入流程填充,兼容旧数据 / 单人无队伍时为 NULL)
*
* @param {string} group
* @param {Array<number>} contestantIds
* @returns {Promise<{byContestant: Object}>} cid → { teamUid, teamNumber }
*/
async _loadTeamMeta(group, contestantIds) {
const { ctx } = this;
const ids = (contestantIds || []).filter(v => v !== null && v !== undefined);
const out = { byContestant: {} };
if (!ids.length) return out;
const Op = ctx.model.Sequelize.Op;
const contestants = await ctx.model.Contestant.findAll({
where: { id: { [Op.in]: ids }, group },
attributes: ['id', 'team_id'],
raw: true,
});
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: { [Op.in]: ids }, group },
attributes: ['account_uid', 'team_uid'],
raw: true,
});
const aggTeamByCid = {};
for (const a of aggRows) {
// 同一 account 可能有多条聚合行(多竞赛报名)⇒ 取第一个非空,保持确定性
if (a.team_uid && !aggTeamByCid[a.account_uid]) aggTeamByCid[a.account_uid] = a.team_uid;
}
const teamUidByCid = {};
for (const c of contestants) {
const uid = aggTeamByCid[c.id] || c.team_id || null;
if (uid) teamUidByCid[c.id] = Number(uid);
}
const uids = Array.from(new Set(Object.values(teamUidByCid)));
if (!uids.length) return out;
// ⚠️ 必带 group_name:team_uid 跨组重号,不带组会把别组队号当成本组的
const teams = await ctx.model.Team.findAll({
where: { team_uid: { [Op.in]: uids }, group_name: group },
attributes: ['team_uid', 'team_id', 'team_number'],
raw: true,
});
const teamByUid = {};
for (const t of teams) teamByUid[t.team_uid] = t;
for (const cid of Object.keys(teamUidByCid)) {
const uid = teamUidByCid[cid];
const t = teamByUid[uid];
out.byContestant[cid] = {
teamUid: uid,
// 人类可读小组编号优先 team_number(形如 BRICS2026-ST-027-91001),回退 team_id
teamNumber: t ? (t.team_number || t.team_id || null) : null,
};
}
return out;
}
/**
* 管理端按三小问给分。
*
@@ -454,6 +536,371 @@ class BigQuestionService extends Service {
return 10;
}
// ==================== 成绩 Excel 导入(R172) ====================
//
// 使用场景:阅卷完成后,评卷人手上只有一张「小组编号 → 三小问得分」的 Excel。
// 逐人在管理端点开评分弹窗录入几百遍既慢又易错,故提供批量导入。
//
// 四条硬约束(改动前务必先读):
// 1) **写入仍只走 setSubScores** —— 即仍经 service.admin.updateScores 同步
// contestants.word_score / score。「成绩发布门控」「总分公式」因此不受影响,
// 本功能绝不新增第二条写成绩的通路。
// 2) **定位键 = (group, 小组编号)**。team_uid 跨组重号 ⇒ 必须带 group 限定;
// 流程为 小组编号 → teams.team_uid → 组内成员 → **唯一的抽题者**。
// 3) **已评分默认拒绝**,需显式 overwrite 才覆盖(用户裁定)。
// 4) **三小问是逐项取值的,不做均分**(R174 用户裁定)。留空即整行拒绝,
// 缺列即整份拒绝,旧两列模板整份拒绝 ⛔ 不得为「兼容」加均分回退分支。
/**
* 生成「小组编号 + 三小问」成绩导入模板(xlsx)。
* sheet 顺序是契约:第 1 页 `成绩导入`(数据,首行表头),第 2 页 `填写说明`。
*
* 🔴 R174:模板由「小组编号 + 分数」两列改为**四列**(小组编号 + 第1/2/3小问)。
* 用户裁定「只认三小问模板」⇒ 总分不再由用户填,改由系统对三项求和。
* ⛔ 不得再往表里加「总分」列 —— 那会立刻产生「总分与三项之和不一致该信谁」的歧义。
*
* @param {string} group
* @returns {Promise<Buffer>}
*/
async buildScoreTemplate(group) {
const groupLabel = GROUP_LABELS[group] || group;
const header = ['小组编号', '第1小问', '第2小问', '第3小问'];
// 示例刻意用「9 / 8.67 / 8.67」而不是三个整数:让评卷人一眼看到可填小数
const example = ['BRICS2026-ST-027-91001', 9, 8.67, 8.67];
const ws = XLSX.utils.aoa_to_sheet([header, example]);
ws['!cols'] = [{ wch: 32 }, { wch: 11 }, { wch: 11 }, { wch: 11 }];
const notes = [
['综合大题 成绩导入模板 — 填写说明'],
[],
[`当前模板适用组别:${groupLabel}(导入时以管理端选中的组别为准,本表不含组别列)`],
[],
['列名', '是否必填', '说明'],
['小组编号', '必填', '与「选手管理 → 选手列表」显示的小组编号完全一致,例如 BRICS2026-ST-027-91001。'
+ '前后空格会自动去掉,但字母大小写必须一致。'],
['第1小问', '必填', '该小组第 1 小问得分,范围 0 ~ 10,可含两位小数(例如 8.67)。'],
['第2小问', '必填', '该小组第 2 小问得分,范围 0 ~ 10,可含两位小数。'],
['第3小问', '必填', '该小组第 3 小问得分,范围 0 ~ 10,可含两位小数。'],
[],
['【组别说明】'],
['本文件不需要填写组别。导入时以「管理端当前选中的组别」为准,',
'若某小组编号属于其他组别,该行会被拒绝并提示切换到对应页签。'],
[],
['【★ 为什么要用小组编号,而不是姓名 / 身份证号】'],
['一个小组只会有一人真正参加考试(抽题作答),但成绩是**组内共享**的:',
'导入后只写在参赛者名下,同组其他成员登录查分时看到的是同一份成绩。',
'因此按小组编号导入即可,无需逐个成员填写。'],
[],
['【★ 三列小问必须全部填写,不能留空】'],
['三小问**逐项填写**,系统只做求和,不做任何均分或补齐:',
'例:9 / 8.67 / 8.67 → 大题总分 26.34(系统自动算,表中不需要填总分)。',
'⚠️ 只要有一问留空,**整行会被拒绝并报错**(例如「第 2 小问未填写」)。',
' 系统不会把留空当 0 分 —— 那样会静默抹掉评卷人已经给出的分数。',
'⚠️ 三列都要保留:不要删除任何一列,也不要改列名,否则整份文件会被拒绝。'],
[],
['【★ 旧版「小组编号 + 分数」两列模板已不再支持】'],
['若你手上还是老的「小组编号 / 分数」两列表格,导入会被整份拒绝并提示',
'「检测到旧版两列模板」。请在管理端重新下载本模板,把总分拆成三小问后再导入。'],
[],
['【★ 已评分的小组会被拒绝】'],
['默认不覆盖既有评分:若该考生已有大题成绩,该行会被跳过并提示。',
'确需覆盖时,在导入对话框里勾选「覆盖已评分」后再导入。'],
[],
['【常见被拒原因(导入时会逐行报错)】'],
['1. 小组编号为空,或在本组 teams 表中不存在(核对空格 / 是否漏填前缀)'],
['2. 小组编号属于其他组别 → 请切换到对应组别页签再导入'],
['3. 同一小组编号在本文件中出现多次 → 全部拒绝(无法判断以哪行为准)'],
['4. 某个小问留空 → 整行拒绝(三问必须齐全)'],
['5. 某个小问不是数值、为负数、或超过 10 分'],
['6. 该小组尚无人抽取综合大题 → 说明还没考试或还没进入作答页,暂不能录分'],
['7. 该小组有多人抽取了大题 → 与「一组一人参赛」不符,需人工先核对'],
['8. 该考生已评分,但未勾选「覆盖已评分」'],
[],
['【Q & A】'],
['Q1:小组编号里有纯数字(如 1、2、3),Excel 会不会转成数值导致匹配不上?'],
['A:不会。系统读取时会转成文本再比对。但若编号带前导零(如 007),',
' Excel 会吞掉前导零 → 请在单元格格式里把该列设为「文本」后再填。'],
['Q2:同一个小组成员要不要每人都填一行?'],
['A:不需要。一个小组填一行即可,成绩自动全组共享。'],
['Q3:表中要不要写总分?'],
['A:不要。总分由系统对三小问求和得到,表里没有也不需要总分列。'],
['Q4:导入后还能改吗?'],
['A:可以。勾选「覆盖已评分」重新导入,或在管理端逐人手工改分。'],
['Q5:导错了怎么办?'],
['A:重新导入正确分数并勾选「覆盖已评分」即可;不会产生重复行(一个考生只有一行大题记录)。'],
];
const notesWs = XLSX.utils.aoa_to_sheet(notes);
notesWs['!cols'] = [{ wch: 16 }, { wch: 10 }, { wch: 92 }];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '成绩导入');
XLSX.utils.book_append_sheet(wb, notesWs, '填写说明');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
/**
* 解析并**预览**成绩导入(零写入)。
*
* 每个 Excel 行都会被判定成一个明确状态;前端据此展示「可导入 / 将覆盖 / 被拒绝(原因)」,
* 管理员确认后才执行写入。⛔ 不做「上传即写库」——成绩误写不可撤销。
*
* @param {string} group
* @param {Buffer} buffer - xlsx 二进制(内存解析,不落盘)
* @returns {Promise<Object>}
*/
async previewScoreImport(group, buffer) {
const { ctx } = this;
if (!this.isEnabledGroup(group)) {
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
}
let aoa;
try {
const wb = XLSX.read(buffer, { type: 'buffer' });
const sheet = wb.Sheets[wb.SheetNames[0]];
// header:1 ⇒ 返回二维数组,保留真实行号;⛔ 不用 sheet_to_json 的对象键,
// 因为表头可能不在第 1 行、且用户表头写法不固定(统一走 bigScoreImport 的别名匹配)
aoa = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true });
} catch (e) {
return { ok: false, code: 'PARSE_FAILED', message: 'Excel 解析失败:' + e.message };
}
const parsed = bigScoreImport.parseScoreRows(aoa);
if (!parsed.ok) return { ok: false, code: parsed.code, message: parsed.message };
// ---- 上下文预查(一次查完,避免逐行打库)----
const Op = ctx.model.Sequelize.Op;
const teamNumbers = Array.from(new Set(parsed.rows.map(r => r.teamNumber).filter(Boolean)));
// ① 本组 teams:team_number 与 team_id 都可能被当作「小组编号」,两者都查
const teamsInGroup = await ctx.model.Team.findAll({
where: {
group_name: group,
[Op.or]: [
{ team_number: { [Op.in]: teamNumbers } },
{ team_id: { [Op.in]: teamNumbers } },
],
},
attributes: ['team_uid', 'team_id', 'team_number'],
raw: true,
});
// ② 他组 teams:用于区分「不存在」与「属于其他组」
const teamsInOther = await ctx.model.Team.findAll({
where: {
group_name: { [Op.ne]: group },
[Op.or]: [
{ team_number: { [Op.in]: teamNumbers } },
{ team_id: { [Op.in]: teamNumbers } },
],
},
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
const indexTeam = (rows) => {
const m = {};
for (const t of rows) {
for (const key of [t.team_number, t.team_id]) {
const k = bigScoreImport.normalizeTeamNumber(key);
if (k && !m[k]) m[k] = t;
}
}
return m;
};
const teamByNumber = indexTeam(teamsInGroup);
const otherByNumber = indexTeam(teamsInOther);
// ③ 各处室上下文:小组成员、抽题者、现有成绩
const teamUids = Array.from(new Set(teamsInGroup.map(t => t.team_uid)));
const members = teamUids.length
? await ctx.model.Contestant.findAll({
where: { group, team_id: { [Op.in]: teamUids } },
attributes: ['id', 'name', 'id_number', 'team_id'],
raw: true,
})
: [];
// 报名聚合里的 team_uid 也算成员(权威来源),与 contestants.team_id 取并集
const aggMembers = teamUids.length
? await ctx.model.Aggregation.findAll({
where: { group, team_uid: { [Op.in]: teamUids } },
attributes: ['account_uid', 'team_uid'],
raw: true,
})
: [];
const memberUidByCid = {};
for (const m of members) memberUidByCid[m.id] = Number(m.team_id);
for (const a of aggMembers) {
if (a.account_uid && !memberUidByCid[a.account_uid]) memberUidByCid[a.account_uid] = Number(a.team_uid);
}
const allCids = Object.keys(memberUidByCid).map(Number);
const contestantInfo = allCids.length
? await ctx.model.Contestant.findAll({
where: { id: { [Op.in]: allCids }, group },
attributes: ['id', 'name', 'id_number'],
raw: true,
})
: [];
const infoByCid = {};
for (const c of contestantInfo) infoByCid[c.id] = c;
const answers = allCids.length
? await ctx.model.BigQuestionAnswer.findAll({
where: { group, contestant_id: { [Op.in]: allCids } },
attributes: [
'id', 'contestant_id', 'aggregation_uid', 'big_question_id',
'score_1', 'score_2', 'score_3', 'total_score',
],
raw: true,
})
: [];
const answerByCid = {};
for (const a of answers) if (a.contestant_id) answerByCid[a.contestant_id] = a;
// ④ 逐行判定(全部走 lib 的纯函数,与自证同源)
const rows = [];
for (const r of parsed.rows) {
const team = r.teamNumber ? teamByNumber[r.teamNumber] : null;
const other = r.teamNumber ? otherByNumber[r.teamNumber] : null;
const teamUid = team ? Number(team.team_uid) : null;
const cidsInTeam = teamUid
? Object.keys(memberUidByCid).filter(cid => memberUidByCid[cid] === teamUid).map(Number)
: [];
const operators = cidsInTeam.filter(cid => !!answerByCid[cid]);
const operatorCid = operators.length === 1 ? operators[0] : null;
const rec = operatorCid ? answerByCid[operatorCid] : null;
const judged = bigScoreImport.classifyScoreRow({
teamNumber: r.teamNumber,
subRaws: r.subRaws,
dupCount: r.dupCount,
foundInGroup: !!team,
foundInOtherGroup: !!other,
otherGroupLabel: other ? (GROUP_LABELS[other.group_name] || other.group_name) : '',
operatorCount: operators.length,
currentTotal: rec ? rec.total_score : null,
subCount: SUB_QUESTION_COUNT,
perSub: rec ? await this.resolveScorePerSub(group, rec) : bigScoreImport.DEFAULT_PER_SUB,
});
const opInfo = operatorCid ? infoByCid[operatorCid] : null;
rows.push({
row: r.row,
teamNumber: r.teamNumber,
subRaws: r.subRaws,
// 三小问填报值(判定通过时才有);前端据此显示「第1/2/3小问」与合计
subs: judged.subs || null,
total: judged.total === undefined ? null : judged.total,
status: judged.status,
message: judged.message,
blocking: bigScoreImport.BLOCKING_STATUS.indexOf(judged.status) >= 0,
// 命中信息(前端展示用;⛔ 不含身份证号以外的敏感字段)
contestantId: operatorCid,
contestantName: opInfo ? opInfo.name : null,
contestantIdNumber: opInfo ? opInfo.id_number : null,
teamUid,
memberCount: cidsInTeam.length,
operatorCount: operators.length,
currentScores: rec
? { score1: rec.score_1, score2: rec.score_2, score3: rec.score_3, total: rec.total_score }
: null,
});
}
const count = s => rows.filter(x => x.status === s).length;
return {
ok: true,
group,
total: rows.length,
importable: count(bigScoreImport.ROW_STATUS.OK),
overwritable: count(bigScoreImport.ROW_STATUS.OVERWRITE),
blocked: rows.filter(x => x.blocking).length,
skippedBlank: count(bigScoreImport.ROW_STATUS.EMPTY_ROW),
rows,
};
}
/**
* 执行成绩导入(先 preview,再逐行写)。
*
* @param {string} group
* @param {Buffer} buffer
* @param {Object} options - { overwrite:boolean }
* @returns {Promise<Object>} { ok, imported, skipped, errors, preview }
*/
async importScores(group, buffer, options = {}) {
const overwrite = options.overwrite === true;
const preview = await this.previewScoreImport(group, buffer);
if (!preview.ok) return preview;
const S = bigScoreImport.ROW_STATUS;
const result = { ok: true, imported: 0, skipped: 0, overwritten: 0, errors: [], preview };
for (const r of preview.rows) {
// 空行不计错误
if (r.status === S.EMPTY_ROW) continue;
// 已评分:未勾选覆盖 ⇒ 跳过(不是失败,但要能看见)
if (r.status === S.OVERWRITE && !overwrite) {
result.skipped++;
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: r.message });
continue;
}
// 其余阻断态一律跳过
if (r.blocking) {
result.skipped++;
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: r.message });
continue;
}
// 到这里只剩 OK / OVERWRITE(且已确认)
if (!r.contestantId) {
result.skipped++;
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: '未解析到参赛考生,已跳过' });
continue;
}
// 三小问逐项写入(⛔ 不再均分;值直接来自判定层 scoreSubs,与预览展示完全一致)
const subs = Array.isArray(r.subs) ? r.subs : [];
if (subs.length !== SUB_QUESTION_COUNT) {
result.skipped++;
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: '三小问得分不完整,请重新预览后再导入' });
continue;
}
const rec = await this._findAnswerByContestant(group, r.contestantId);
if (!rec) {
result.skipped++;
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: '大题作答记录已消失,请重新预览' });
continue;
}
// 🔴 写入走唯一评分路径(setSubScores → service.admin.updateScores)
const written = await this.setSubScores(group, rec.aggregation_uid, {
score_1: subs[0],
score_2: subs[1],
score_3: subs[2],
});
if (!written.ok) {
result.skipped++;
result.errors.push({ row: r.row, teamNumber: r.teamNumber, message: written.message });
continue;
}
result.imported++;
if (r.status === S.OVERWRITE) result.overwritten++;
}
return result;
}
/** 按考生取大题作答记录(组内限定,防跨组同名聚合 uid) */
async _findAnswerByContestant(group, contestantId) {
const { ctx } = this;
return ctx.model.BigQuestionAnswer.findOne({
where: { group, contestant_id: contestantId },
attributes: ['id', 'contestant_id', 'aggregation_uid', 'total_score'],
});
}
// ==================== 题库管理(管理端) ====================
/**
@@ -845,6 +1292,16 @@ class BigQuestionService extends Service {
runs: [{ text: `${groupLabel}综合大题阅卷表`, bold: true, size: 32 }],
});
blocks.push({ type: 'p', runs: [{ text: `姓名:${s.name || '--'}  身份证号:${s.idNumber || '--'}  手机号:${s.phone || '--'}` }] });
// R172(用户要求):**在 Word 文件内用红色字体标注小组编号**。
// 阅卷人按小组编号分发 / 归档 / 核对纸卷,需一眼可辨,故独立一行、红色加粗。
// ⚠️ 没有小组编号时显式印「(无)」而**不是省略该行**:省略会让人误判成「导出漏了字段」。
blocks.push({
type: 'p',
runs: [
{ text: '小组编号:', bold: true, color: 'FF0000' },
{ text: s.teamNumber || '(无)', bold: true, color: 'FF0000' },
],
});
blocks.push({ type: 'p', runs: [{ text: `抽到题目:${snap.title || '--'}(${s.bigQuestionUid || '--'})` }] });
blocks.push({ type: 'p', runs: [{ text: `提交时间:${fmtTime(s.submitTime)}  已答小问:${s.answeredCount} / ${SUB_QUESTION_COUNT}` }] });

View File

@@ -402,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
@@ -412,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

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,35 +78,359 @@ 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;
async saveWorkFile(stream, filename, group, folderName, opts = {}) {
const { fileType = 'word', aggregationUid = null } = opts;
const target = await this.prepareWorkTarget({
group, folderName, filename, fileType, aggregationUid,
});
// 确保上传目录存在(无条件递归创建,避免父目录偶发缺失 / 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;
}
// 临时名:以 `.` 开头 + 固定前缀,便于残留扫描识别;同目录保证 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 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);
const writeStream = fs.createWriteStream(tmpPath);
let fileSize = 0;
try {
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
stream.on('data', chunk => { fileSize += chunk.length; });
@@ -113,28 +442,20 @@ class UploadService extends Service {
// 🔴 落盘完整性校验(F17-B 防「假成功」):
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
const stat = fs.statSync(filePath);
const stat = fs.statSync(tmpPath);
if (stat.size !== fileSize) {
// 🔴 清理半截文件用 best-effort(同源缺陷族第三处):
// 裸 unlinkSync 一旦失败会抛出「删除失败」,把下面那条真正有诊断价值的
// 「文件写入不完整:期望 X 实际 Y」**顶掉**,排障时被引向错误方向。
// 清理成不成功都不影响结论——写入不完整这件事必须原样抛出。
this.ctx.helper.safeCleanup(filePath, { label: 'partialFile' });
// 🔴 清理半截文件用 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);
}
/**
@@ -291,9 +612,12 @@ class UploadService extends Service {
score: null, // 重新上传后清除评分
});
if (stalePath) {
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}`
@@ -374,8 +698,14 @@ class UploadService extends Service {
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
// 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径,
// 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。
//
// 🔴 R157:删除结果**不再丢弃**。此前返回值被完全忽略 ⇒ 删除失败只进日志,
// 运行期无计数、无暴露,孤儿文件在服务侧完全不可见(只能靠离线扫描发现,
// 而离线扫描报出的孤儿无法回溯「是哪一次删除失败的」)。
// 修法:累计到**服务实例计数器 + 暴露 getter**,⛔ 不抛错(保持 best-effort 语义)。
if (record.file_path) {
this.removePublicFile(record.file_path, 'deleteSubmission');
const r = this.removePublicFile(record.file_path, 'deleteSubmission');
this._noteDeleteResult(r, record.file_path, 'deleteSubmission');
}
await record.destroy();
@@ -391,6 +721,7 @@ class UploadService extends Service {
* 一旦删除失败就静默留下一份无人认领的文件(孤儿)。
* 现收敛为**唯一入口**:所有「按 DB 里的相对路径删文件」都必须经过这里。
*
/**
* 【安全闸门】只允许删除 `works/` 与 `tmp/` 前缀下的文件:
* - DB 里的 file_path 天然是 `works/<group>/<file>`;前缀白名单可挡住
* 「脏数据 / 手工改库 / SQL 注入」把 `file_path` 指向 `templates/`、`carousel/`
@@ -404,6 +735,48 @@ class UploadService extends Service {
* @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;

View File

@@ -379,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',
@@ -499,6 +508,71 @@ 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 触发)

View File

@@ -4,43 +4,38 @@
// 口令:下方内置 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();
// `admins` 表列契约:id(自增主键,勿传)/ username / password / created_at / updated_at。
// ⛔ 该表**无** salt / role / name_cn / id_number / activated / score 等列。
const row = {
role: 'admin',
name_cn: '系统管理员',
name_en: 'Administrator',
gender: null,
phone: null,
email: 'admin@competition.com',
id_number: '000000000000000000',
username: 'admin',
// bcrypt(cost=10) 哈希 = 开发默认弱口令(公开值,仅供本机初始化;生产须经 ADMIN_INIT_PASSWORD 覆盖)。
// 60 字符 < `admins.password VARCHAR(100)` ⇒ 列宽容得下。
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,
};
// 若提供 ADMIN_INIT_PASSWORD,则以之为准重算 bcrypt 哈希(salt 取哈希前 29 字符的 bcrypt 盐前缀)。
// 若提供 ADMIN_INIT_PASSWORD,则以之为准重算 bcrypt 哈希(明文只经过程环境,⛔ 不落盘)。
const initPwd = process.env.ADMIN_INIT_PASSWORD;
if (initPwd) {
const hash = require('bcrypt').hashSync(String(initPwd), 10);
row.password = hash;
row.salt = hash.slice(0, 29);
row.password = require('bcrypt').hashSync(String(initPwd), 10);
}
await queryInterface.bulkInsert('accounts', [row], {});
await queryInterface.bulkInsert('admins', [row], {});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('accounts', {
id_number: '000000000000000000',
await queryInterface.bulkDelete('admins', {
username: 'admin',
}, {});
},
};

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