Compare commits

..

2 Commits

Author SHA1 Message Date
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
31 changed files with 5286 additions and 96 deletions

11
.gitignore vendored
View File

@@ -55,6 +55,13 @@ app/public/carousel/
app/public/tmp/ app/public/tmp/
app/public/exports/ app/public/exports/
# Tests (non-required; code runs without them) — 上传目的=运行必须,test 不入库 # Tests —— 上传目的=运行必须:`test/` 下的夹具 / 临时物 / 子目录**不入库**。
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/ test_dir/

23
app.js
View File

@@ -112,6 +112,29 @@ class AppBootHook {
} catch (e) { } catch (e) {
this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message)); 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,6 +10,8 @@ const { sanitizeUploadFileName } = require('../utils/filename');
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard'); const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
// 🔴 同源 collapse(缺口9 修复):所有文件发送通道统一走 sendFile util // 🔴 同源 collapse(缺口9 修复):所有文件发送通道统一走 sendFile util
const { sendFile } = require('../utils/sendFile'); const { sendFile } = require('../utils/sendFile');
// R178 高危操作审计(best-effort,不阻断业务)
const { recordAudit, ACTIONS: AUDIT_ACTIONS } = require('../lib/auditLog');
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入 // 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。 // TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
const { requireTestPassword } = require('../lib/passwordPolicy'); const { requireTestPassword } = require('../lib/passwordPolicy');
@@ -163,6 +165,11 @@ class AdminController extends Controller {
const { ctx, service, app } = this; const { ctx, service, app } = this;
if (!this._ensureAdmin()) return; if (!this._ensureAdmin()) return;
// 🔴 R161:临时文件路径必须在 try **外**声明,才能被 finally 看到。
// 原实现把 `unlinkSync` 写在 `await importFromExcel` 之后、外层 catch 之前
// ⇒ 解析/导入一抛错就跳过清理,永久泄漏 tmp/excel/import_*.xlsx(实测存量 41 个)。
let tempFilePath = null;
try { try {
// 获取上传的文件流 // 获取上传的文件流
const stream = await ctx.getFileStream(); const stream = await ctx.getFileStream();
@@ -200,8 +207,7 @@ class AdminController extends Controller {
const testFlag = rawTestFlag === '1' || rawTestFlag === 'true' || rawTestFlag === true; const testFlag = rawTestFlag === '1' || rawTestFlag === 'true' || rawTestFlag === true;
const result = await service.admin.importFromExcel(tempFilePath, { testFlag }); const result = await service.admin.importFromExcel(tempFilePath, { testFlag });
// 清理临时文件 // 🔴 R161:清理已移至外层 finally(此处不再单独清理,避免重复与漏清)。
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
// 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败 // 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败
const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : ''; const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : '';
@@ -218,6 +224,178 @@ class AdminController extends Controller {
const msg = (err && err.message) ? err.message : '未知错误'; const msg = (err && err.message) ? err.message : '未知错误';
// 注意:前端 onExcelImport 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀 // 注意:前端 onExcelImport 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀
ctx.body = ctx.helper.error(500, msg); 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 : '导出成绩公示表失败');
}
}
// ==================== 赛项信息配置(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, '保存赛项信息失败');
} }
} }
@@ -338,7 +516,7 @@ class AdminController extends Controller {
* 文件重命名为 "组号-学校-姓名-原文件名" 格式,流式下载 ZIP * 文件重命名为 "组号-学校-姓名-原文件名" 格式,流式下载 ZIP
*/ */
async batchDownload() { async batchDownload() {
const { ctx, service } = this; const { ctx, service, app } = this;
if (!this._ensureAdmin()) return; if (!this._ensureAdmin()) return;
try { try {
@@ -354,8 +532,18 @@ class AdminController extends Controller {
await sendFile(ctx, this.app, { absolutePath: zipFilePath, fileName, contentType: 'application/zip', useXAccel: false }); await sendFile(ctx, this.app, { absolutePath: zipFilePath, fileName, contentType: 'application/zip', useXAccel: false });
// 下载完成后清理 ZIP 文件(Node 流模式下 res.finish 在流发完后触发) // 下载完成后清理 ZIP 文件(Node 流模式下 res.finish 在流发完后触发)
// 🔴 R161:原实现只删 zipFilePath,**漏删 batchDownloadZip 建的 zipDir 工作目录**
// (tmp/zip/<group>/<ts>/*_chunk.pdf,实测残留 340 文件 / 4 目录 ⇒ 每次导出必泄漏)。
// 改用 safeCleanup(三态可观测,失败 logger.warn 不静默)并补删工作目录。
ctx.res.on('finish', () => { 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) { } catch (err) {
ctx.logger.error('批量下载失败:', err); ctx.logger.error('批量下载失败:', err);
@@ -428,6 +616,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, '获取节点备份状态失败');
}
}
// ==================== 评分管理 ==================== // ==================== 评分管理 ====================
/** /**
@@ -623,6 +861,9 @@ class AdminController extends Controller {
const { group } = ctx.params; const { group } = ctx.params;
if (!this._validateGroup(group)) return; if (!this._validateGroup(group)) return;
// 🔴 R161:须在 try 外声明,finally 才可见(原来没有 finally ⇒ 抛错即泄漏)。
let tempFilePath = null;
try { try {
const stream = await ctx.getFileStream(); const stream = await ctx.getFileStream();
if (!stream) { if (!stream) {
@@ -639,7 +880,7 @@ class AdminController extends Controller {
const tempDir = path.join(app.baseDir, 'app/public/tmp/excel'); const tempDir = path.join(app.baseDir, 'app/public/tmp/excel');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); 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); const writeStream = fs.createWriteStream(tempFilePath);
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@@ -651,14 +892,19 @@ class AdminController extends Controller {
const result = await service.admin.importQuestionsFromExcel(tempFilePath, { group }); 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} 条`); ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`);
} catch (err) { } catch (err) {
ctx.logger.error('客观题 Excel 导入失败:', err); ctx.logger.error('客观题 Excel 导入失败:', err);
const msg = (err && err.message) ? err.message : '未知错误'; const msg = (err && err.message) ? err.message : '未知错误';
// 前端 onExcelFileChange 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀 // 前端 onExcelFileChange 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀
ctx.body = ctx.helper.error(500, msg); 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 +1027,15 @@ class AdminController extends Controller {
await service.admin.publishScores(publish, group); 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 ? '该组成绩' : '全部组成绩'; const scopeLabel = group ? '该组成绩' : '全部组成绩';
ctx.body = ctx.helper.success( ctx.body = ctx.helper.success(
{ published: publish, group: group || null }, { published: publish, group: group || null },
@@ -1132,6 +1387,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 文件 * 下载考生答案 Word 文件
* GET /api/admin/submissions/:group/:id/word * GET /api/admin/submissions/:group/:id/word
@@ -1771,6 +2113,16 @@ class AdminController extends Controller {
results.jiaoshiSubmissionsDeleted = jiaoshiDeleted; results.jiaoshiSubmissionsDeleted = jiaoshiDeleted;
ctx.logger.info('[测试数据重置]', results); 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}」,需先用默认口令完成短信激活即可登录重测`); ctx.body = ctx.helper.success(results, `测试数据已重置:${contestantCount} 名选手,${examDeleted + workDeleted} 条提交记录。已恢复为「未激活 + 默认口令 ${testPw}」,需先用默认口令完成短信激活即可登录重测`);
} catch (err) { } catch (err) {
ctx.logger.error('重置测试数据失败:', err); ctx.logger.error('重置测试数据失败:', err);
@@ -1900,7 +2252,10 @@ class AdminController extends Controller {
ctx.body = ctx.helper.error(500, '导入失败:' + msg); ctx.body = ctx.helper.error(500, '导入失败:' + msg);
} finally { } 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 +2337,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 * 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 模块头)。 // ⚠️ 任何新增的口令写入入口都必须调用它,禁止在控制器内自行判断(见 lib/passwordPolicy.js 模块头)。
const passwordPolicy = require('../lib/passwordPolicy'); const passwordPolicy = require('../lib/passwordPolicy');
// R178:管理端权限点解析(与 middleware/auth.js 同一真相源)
const adminPermissions = require('../lib/adminPermissions');
/** /**
* 认证控制器 - AuthController * 认证控制器 - AuthController
@@ -372,6 +374,15 @@ class AuthController extends Controller {
return; return;
} }
// 🔴 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), // P2-3 默认口令守卫:内置默认管理员(见 database/schema.sql 与 database/seeders/002-seed-admin.js),
// 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管; // 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管;
// 非生产环境放行但记录告警,并在响应中提示尽快改密。 // 非生产环境放行但记录告警,并在响应中提示尽快改密。
@@ -410,14 +421,33 @@ class AuthController extends Controller {
const token = ctx.helper.generateToken({ const token = ctx.helper.generateToken({
id: admin.id, id: admin.id,
role: 'admin', 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); }, 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({ ctx.body = ctx.helper.success({
token, token,
user: { user: {
id: admin.id, id: admin.id,
username: admin.username, username: admin.username,
role: 'admin', role: 'admin',
// 前端据 isSuper 决定「管理员账号管理」入口与最高管理员的只读展示
isSuper: Number(admin.is_super) === 1,
// 权限点数组:前端据此过滤侧边菜单与按钮。后端仍逐请求校验,前端过滤只是体验层。
permissions: adminPermissions.parsePermissions(admin.permissions),
}, },
// 使用默认口令时向前端透出改密提醒标记 // 使用默认口令时向前端透出改密提醒标记
mustChangePassword: usingDefaultPassword, mustChangePassword: usingDefaultPassword,

View File

@@ -389,6 +389,12 @@ class ExamController extends Controller {
return; return;
} }
// 🔴 交卷冻结(R178·B①):已正式交卷者不得再改写答案。
// 位置刻意在判分之前 —— 已冻结的答卷不该再做一次无意义的 autoScore。
// 与 service.saveAnswers 内的兜底调用是**同一个函数**(单一判据源),
// 不是两套条件;异常由下方 catch 按 bizCode 翻译成 4006。
await service.exam.assertNotSubmitted(aggregation.aggregation_uid);
// 参数校验 // 参数校验
const rule = { const rule = {
answers: { type: 'array', required: true, min: 1 }, answers: { type: 'array', required: true, min: 1 },
@@ -452,6 +458,15 @@ class ExamController extends Controller {
savedCount: savedRecords.length, savedCount: savedRecords.length,
}, '答案提交成功'); }, '答案提交成功');
} catch (err) { } 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.logger.error('提交答案失败:', err);
ctx.body = ctx.helper.error(500, '提交答案失败'); ctx.body = ctx.helper.error(500, '提交答案失败');
} }

View File

@@ -191,6 +191,8 @@ class UploadController extends Controller {
* *
* Body: multipart/form-data 包含 chunk(文件分片)、chunkIndex、totalChunks、fileId、fileName * Body: multipart/form-data 包含 chunk(文件分片)、chunkIndex、totalChunks、fileId、fileName
*/ */
// @no-temp-cleanup: 分片接收端,临时目录必须**保留**待 mergeChunks 合并;
// 若在此清理则分片无法续传。清理责任在 mergeChunks(upload.js:342 safeCleanup)。
async uploadChunk() { async uploadChunk() {
const { ctx, app } = this; const { ctx, app } = this;
@@ -389,6 +391,8 @@ class UploadController extends Controller {
* 查询分片上传进度 * 查询分片上传进度
* GET /api/upload/:group/chunk-status?fileId=xxx * GET /api/upload/:group/chunk-status?fileId=xxx
*/ */
// @no-temp-cleanup: 本方法**只读探测**临时目录(fs.existsSync + readdirSync),
// 不创建也不删除任何文件 ⇒ 「作用域内无清理」是正确形态,不是缺口。
async chunkStatus() { async chunkStatus() {
const { ctx, app } = this; const { ctx, app } = this;

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

@@ -0,0 +1,284 @@
'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']],
// ---------- 成绩管理(阅卷/导出/公示表) ----------
['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']],
['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:'p', runs:[...], align, firstLine }
* { type:'table', rows:[{ cells:[{ blocks:[...] }] }] } * { 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) { function walk(nodes, fmt, out) {
const flush = () => { const flush = () => {
@@ -274,11 +275,31 @@ function textToBlocks(text) {
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; 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) { function rPrOf(run) {
const parts = []; const parts = [];
if (run.bold) parts.push('<w:b/><w:bCs/>'); if (run.bold) parts.push('<w:b/><w:bCs/>');
if (run.italic) parts.push('<w:i/><w:iCs/>'); 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"/>'); 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; const sz = Number(run.size) > 0 ? Number(run.size) : 21;
parts.push(`<w:sz w:val="${sz}"/><w:szCs w:val="${sz}"/>`); parts.push(`<w:sz w:val="${sz}"/><w:szCs w:val="${sz}"/>`);
return `<w:rPr>${parts.join('')}</w:rPr>`; return `<w:rPr>${parts.join('')}</w:rPr>`;
@@ -464,6 +485,6 @@ module.exports = {
textToBlocks, textToBlocks,
decodeEntities, decodeEntities,
esc, esc,
// 导出内部件便于单测 // 导出内部件便于单测(`scripts/verify_big_score_import.py` 会断言 color 渲染与序列位置)
_internal: { parseHtml, buildDocumentXml, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS }, _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,
};

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

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

View File

@@ -1,6 +1,8 @@
'use strict'; 'use strict';
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
// R178:管理端权限判定(单一真相源,见 lib/adminPermissions.js)
const adminPermissions = require('../lib/adminPermissions');
/** /**
* JWT 认证中间件 * JWT 认证中间件
@@ -81,6 +83,77 @@ module.exports = (options = {}, app) => {
}; };
return; 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 => { module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize; const { STRING, INTEGER, DATE, TEXT, TINYINT } = app.Sequelize;
const Admin = app.model.define('admin', { const Admin = app.model.define('admin', {
// 主键 ID,自增 // 主键 ID,自增
@@ -30,6 +30,51 @@ module.exports = app => {
allowNull: false, allowNull: false,
comment: '管理员密码', 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: { created_at: {
type: DATE, 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() { 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, { app.model.Aggregation.belongsTo(app.model.CompetitionInfo, {
foreignKey: 'competition_uid', foreignKey: 'competition_uid',
targetKey: 'competition_uid', targetKey: 'id',
as: 'competition', as: 'competition',
}); });
app.model.Aggregation.belongsTo(app.model.Team, { app.model.Aggregation.belongsTo(app.model.Team, {

View File

@@ -3,56 +3,66 @@
/** /**
* 竞赛信息模型 - CompetitionInfo * 竞赛信息模型 - CompetitionInfo
* *
* 存储竞赛的基本信息,包括竞赛名称和代码。
* 表名:competition_info * 表名: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 => { module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize; const { STRING, INTEGER, TEXT, DATE } = app.Sequelize;
const CompetitionInfo = app.model.define('competition_info', { const CompetitionInfo = app.model.define('competition_info', {
// 主键,自增 // 主键,自增(运行库真实主键是 id,不是 competition_uid)
competition_uid: { id: {
type: INTEGER, type: INTEGER,
primaryKey: true, primaryKey: true,
autoIncrement: true, autoIncrement: true,
comment: '竞赛唯一标识', comment: '自增主键',
}, },
// 竞赛名称 // 配置键名(如 competition_name / competition_code / competition_item_name)
competition_name: { // ⚠️ 运行库**没有**给 info_key 建唯一索引 ⇒ 写侧必须"先查再写",不能依赖 upsert
type: STRING(200), info_key: {
allowNull: false,
comment: '竞赛名称',
},
// 竞赛代码(唯一标识码)
competition_code: {
type: STRING(50), type: STRING(50),
allowNull: false, allowNull: false,
unique: true, comment: '配置键',
comment: '竞赛代码', },
// 配置值(统一按字符串存,数字也以文本形式落库)
info_value: {
type: TEXT,
allowNull: true,
comment: '配置值',
}, },
// 记录创建时间
created_at: { created_at: {
type: DATE, type: DATE,
allowNull: false, allowNull: true,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间', comment: '创建时间',
}, },
// 记录更新时间
updated_at: { updated_at: {
type: DATE, type: DATE,
allowNull: false, allowNull: true,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间', comment: '更新时间',
}, },
}, { }, {
tableName: 'competition_info', tableName: 'competition_info',
timestamps: false, timestamps: false,
underscored: true, underscored: true,
// 索引仅作 Sequelize 元数据声明(sync 时用);⛔ 本项目的表由 SQL 手工维护,
// 不要指望这里加 unique 会同步到运行库
indexes: [ indexes: [
{ {
unique: true, fields: ['info_key'],
fields: ['competition_code'],
}, },
], ],
}); });

View File

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

View File

@@ -79,6 +79,30 @@ module.exports = app => {
defaultValue: null, defaultValue: null,
comment: '标签,多个标签用逗号分隔', 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: { created_at: {
type: DATE, 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); 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.get('/api/admin/teams', adminAuth, controller.admin.teams);
router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel); 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):让「导入之后就改不了」的历史限制成为过去 —— 身份证号 / 姓名 / // 选手信息编辑(R108):让「导入之后就改不了」的历史限制成为过去 —— 身份证号 / 姓名 /
// 手机号可事后纠正(典型场景:报名表证号录入错误,人工核实后修正)。 // 手机号可事后纠正(典型场景:报名表证号录入错误,人工核实后修正)。
// ⚠️ 路径段固定(/contestants/:id),与既有路由无匹配冲突;PUT 方法在 admin 段内唯一。 // ⚠️ 路径段固定(/contestants/:id),与既有路由无匹配冲突;PUT 方法在 admin 段内唯一。
@@ -132,6 +151,14 @@ module.exports = app => {
router.get('/api/admin/export-csv/:group', adminAuth, controller.admin.exportCsv); router.get('/api/admin/export-csv/:group', adminAuth, controller.admin.exportCsv);
// R29:成绩导出改为真正的 Excel(.xlsx)(与 CSV 共用同一数据源 buildScoreExportTable) // R29:成绩导出改为真正的 Excel(.xlsx)(与 CSV 共用同一数据源 buildScoreExportTable)
router.get('/api/admin/export-excel/:group', adminAuth, controller.admin.exportExcel); 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);
// 赛项信息(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); router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload);
router.get('/api/admin/works/:group', adminAuth, controller.admin.works); router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
@@ -139,6 +166,10 @@ module.exports = app => {
router.get('/api/admin/times', adminAuth, controller.admin.getTimes); router.get('/api/admin/times', adminAuth, controller.admin.getTimes);
router.put('/api/admin/times', adminAuth, controller.admin.setTimes); 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.get('/api/admin/scores/:group', adminAuth, controller.admin.getScores);
router.put('/api/admin/scores/:group', adminAuth, controller.admin.setScores); router.put('/api/admin/scores/:group', adminAuth, controller.admin.setScores);
@@ -169,6 +200,12 @@ module.exports = app => {
router.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers); router.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers);
// 考生答卷导出(R28):3 段路径,与上方 2 段列表、下方 PUT /:uid/scores 均不冲突 // 考生答卷导出(R28):3 段路径,与上方 2 段列表、下方 PUT /:uid/scores 均不冲突
router.get('/api/admin/big-answers/:group/export', adminAuth, controller.admin.exportBigAnswers); router.get('/api/admin/big-answers/:group/export', adminAuth, controller.admin.exportBigAnswers);
// 成绩 Excel 导入(R172):两段式(预览零写入 → 确认导入),均为 3 段路径,与 PUT /:uid/scores 不冲突。
// ⚠️ 具体段(import-template / import-preview / import-scores)不得与将来的 `/:uid` 通配段冲突 ⇒
// 保持与 export 同等注册位置(在 PUT 之前)。
router.get('/api/admin/big-answers/:group/import-template', adminAuth, controller.admin.downloadBigScoreTemplate);
router.post('/api/admin/big-answers/:group/import-preview', adminAuth, controller.admin.previewBigScoreImport);
router.post('/api/admin/big-answers/:group/import-scores', adminAuth, controller.admin.importBigScores);
router.put('/api/admin/big-answers/:group/:uid/scores', adminAuth, controller.admin.setBigAnswerScores); router.put('/api/admin/big-answers/:group/:uid/scores', adminAuth, controller.admin.setBigAnswerScores);
// 备赛资料管理 // 备赛资料管理
@@ -196,6 +233,9 @@ module.exports = app => {
// 图片上传(轮播图等) // 图片上传(轮播图等)
router.post('/api/admin/upload/image', adminAuth, controller.admin.uploadImage); router.post('/api/admin/upload/image', adminAuth, controller.admin.uploadImage);
router.post('/api/admin/upload/news-image', adminAuth, controller.admin.uploadNewsImage); 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 文件 // 下载考生答案 Word 文件
router.get('/api/admin/submissions/:group/:id/word', adminAuth, controller.admin.downloadWord); router.get('/api/admin/submissions/:group/:id/word', adminAuth, controller.admin.downloadWord);

View File

@@ -29,6 +29,78 @@ const { inspectIdNumber } = require('../lib/idCard');
const OBJECTIVE_SCORE_PER_QUESTION = 1; const OBJECTIVE_SCORE_PER_QUESTION = 1;
const OBJECTIVE_TYPES = ['choice', 'judge']; const OBJECTIVE_TYPES = ['choice', 'judge'];
/**
* 批量解析「小组编号」标签(R178 · 成绩管理「小组编号」列)
*
* 需求原文:「成绩管理中,需要有小组编号来方便查证小组是否完成考试。」
* 成绩总览原本只有姓名/身份证号,无法按小组归并核对 ⇒ 补一列人类可读的小组编号。
*
* 🔴 口径必须与「选手管理」一致,⛔ 不得另立第二套(对照 `getAccounts` 的 R90 段):
* · 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)
* · teamUid 解析优先级:`aggregation.team_uid`(报名表聚合,权威)→ `contestants.team_id`(兜底)
* · 展示值取 `teams.team_id`(人类可读队伍编号,如 BRICS2026-ST-027-91001),
* 缺 `team_number` 时回落同一值 —— 与选手管理 `teamNumber` 口径相同
*
* ⚠️ 为什么不能直接展示 `contestants.team_id`:该列是**内部数字**(实测王学卿=8),
* 与选手管理展示的报名队伍编号不是一回事;两页显示不同值会让「按编号核对」失效。
* ⚠️ 为什么不能只查 teams 表:实测中职组张明远 `aggregation.team_uid=1` 但 teams 表
* 没有 (zhongzhi, 1) 行 —— 属「有归属无元信息」的历史数据,此时标签为 null(前端显示「未编组」)。
*
* @param {Object} ctx - Egg 上下文(用 ctx.model 取模型)
* @param {Array<{id:number, group:string, team_id:?number}>} contestants
* @returns {Promise<Map<string,{teamUid:?number,teamId:?string,teamNumber:?string}>>}
* 键 = String(contestant.id);无归属者不出现在 Map 中(调用方按 null 处理)
*/
async function resolveTeamLabels(ctx, contestants) {
const out = new Map();
if (!Array.isArray(contestants) || contestants.length === 0) return out;
const Op = ctx.model.Sequelize.Op;
const ids = contestants.map(c => c.id);
// 1) aggregation.team_uid(权威来源)
const aggTeamUid = new Map();
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: { [Op.in]: ids } },
attributes: ['account_uid', 'team_uid'],
raw: true,
});
for (const a of aggRows) {
if (a.team_uid === null || a.team_uid === undefined) continue;
if (!aggTeamUid.has(a.account_uid)) aggTeamUid.set(a.account_uid, a.team_uid);
}
// 2) 逐人定 teamUid(aggregation 优先 → contestants.team_id 兜底)
const teamUidOf = c => {
if (aggTeamUid.has(c.id)) return aggTeamUid.get(c.id);
return c.team_id === null || c.team_id === undefined ? null : c.team_id;
};
// 3) 一次性取队伍元信息(按 (group, teamUid) 建索引,避免跨组重号串组)
const uids = Array.from(new Set(contestants.map(teamUidOf).filter(u => u !== null)));
const meta = new Map();
if (uids.length > 0) {
const teamRows = await ctx.model.Team.findAll({
where: { team_uid: { [Op.in]: uids } },
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
for (const t of teamRows) meta.set(t.group_name + '#' + t.team_uid, t);
}
for (const c of contestants) {
const teamUid = teamUidOf(c);
if (teamUid === null) continue;
const m = meta.get(c.group + '#' + teamUid) || null;
out.set(String(c.id), {
teamUid,
teamId: m ? m.team_id : null,
teamNumber: m ? (m.team_number || m.team_id) : null,
});
}
return out;
}
/** /**
* 管理后台服务 - AdminService * 管理后台服务 - AdminService
* *
@@ -75,15 +147,25 @@ class AdminService extends Service {
}); });
const contestantIds = contestants.map(c => c.id); const contestantIds = contestants.map(c => c.id);
if (contestantIds.length > 0) { if (contestantIds.length > 0) {
const SubmissionModel = (group === 'zhongzhi' || group === 'gaozhi') const isExamGroup = (group === 'zhongzhi' || group === 'gaozhi');
const SubmissionModel = isExamGroup
? ctx.model.ExamSubmission ? ctx.model.ExamSubmission
: ctx.model.WorkSubmission; : ctx.model.WorkSubmission;
const submissions = await SubmissionModel.findAll({ const submissions = await SubmissionModel.findAll({
where: { contestant_id: contestantIds }, where: { contestant_id: contestantIds },
attributes: ['contestant_id', 'score'], attributes: isExamGroup
? ['contestant_id', 'score', 'submit_time']
: ['contestant_id', 'score'],
raw: true, raw: true,
}); });
const uniqueIds = new Set(submissions.map(s => s.contestant_id)); // 🔴 R178(B④)仪表盘「已提交」口径修正
// 旧实现直接数 exam_submissions 的**行数**,而进考场就会建占位行(submit_time=NULL)
// ⇒ 只是进过考场的人全被算成「已提交」,提交数与提交率双双虚高。
// 现在中职/高职只统计正式交卷(submit_time 非空);本科/教师仍按「上传作品即有行」。
const effective = isExamGroup
? submissions.filter(s => !!s.submit_time)
: submissions;
const uniqueIds = new Set(effective.map(s => s.contestant_id));
submittedCount = uniqueIds.size; submittedCount = uniqueIds.size;
totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0); totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
} }
@@ -545,11 +627,16 @@ class AdminService extends Service {
// 取一个可用的竞赛 ID 用于聚合报名记录(aggregation.competition_uid 非空约束) // 取一个可用的竞赛 ID 用于聚合报名记录(aggregation.competition_uid 非空约束)
let competitionUid = 1; let competitionUid = 1;
try { try {
// 🔴 R176:competition_info 是 key-value 表(info_key / info_value),
// 不是三列结构。原实现 `findOne({ attributes:['competition_uid'] })`
// 会抛 `Unknown column`,被下方 catch 吞掉后静默回落默认值 1 ——
// 恰好 info_key='competition_uid' 的值也是 1,所以这个 bug 一直不可见。
const comp = await ctx.model.CompetitionInfo.findOne({ const comp = await ctx.model.CompetitionInfo.findOne({
attributes: ['competition_uid'], attributes: ['info_value'],
order: [['competition_uid', 'DESC']], where: { info_key: 'competition_uid' },
}); });
if (comp && comp.competition_uid) competitionUid = comp.competition_uid; const parsed = comp && comp.info_value ? parseInt(comp.info_value, 10) : NaN;
if (isFinite(parsed) && parsed > 0) competitionUid = parsed;
} catch (e) { } catch (e) {
ctx.logger.warn('[import] 获取竞赛 ID 失败,使用默认 1:', e.message); ctx.logger.warn('[import] 获取竞赛 ID 失败,使用默认 1:', e.message);
} }
@@ -730,6 +817,188 @@ class AdminService extends Service {
return result; return result;
} }
/**
* 指导教师回填(R176)
*
* 用户裁定:成绩公示表必须有「指导教师」列,但系统原本无任何承载字段。
* 报名表里其实有教师行(`身份` = 指导教师/教练…),只是导入时按业务规则
* 只导入「参赛选手」身份,教师行被计入 ignored 丢弃。
*
* 🔴 为什么不做成「重跑一次 importContestants」:
* 该流程对已存在的身份证号一律 `skipped++`,重跑不会更新任何字段,
* 只是把整张表再报一遍「已存在」。故单独开一条**只写 teacher_name** 的通路。
* 🔴 为什么必须带 group:`teams.team_uid` 仅组内唯一、可跨组重号
* ⇒ 归并键 = (group, 报名表队伍编号 → teams.team_id)。
* 🔴 本方法**只**写 contestants.teacher_name,不碰账号 / 队伍 / 成绩 / 激活态。
*
* @param {Buffer} buffer 报名表 xlsx 原始字节(内存解析,不落盘:
* `app/public` 对静态服务开放,报名表含身份证号,落盘即可被猜名下载)
* @param {Object} [options]
* @param {string} [options.group] 仅回填指定组别(不传 = 表里出现的全部组别)
* @param {boolean} [options.overwrite=true] false = 只填空缺,不覆盖已有值
* @returns {Promise<Object>}
*/
async backfillTeachers(buffer, options = {}) {
const { ctx } = this;
const teacherBackfill = require('../lib/teacherBackfill');
const { Op } = ctx.app.Sequelize;
if (!buffer || !buffer.length) {
return { ok: false, code: 'EMPTY_BUFFER', message: '未收到文件内容' };
}
let aoa;
try {
const workbook = XLSX.read(buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
return { ok: false, code: 'EMPTY_FILE', message: '文件里没有工作表' };
}
aoa = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { header: 1, blankrows: true });
} catch (e) {
return { ok: false, code: 'PARSE_FAILED', message: `无法解析为 Excel:${e.message}` };
}
const parsed = teacherBackfill.extractTeachers(aoa);
if (!parsed.ok) {
return {
ok: false,
code: parsed.code,
message: parsed.message,
stats: parsed.stats,
warnings: parsed.errors,
};
}
// 可选:只处理指定组别(管理端按组操作时避免误改其他组)
const onlyGroup = options.group ? String(options.group) : '';
const targets = onlyGroup
? parsed.teams.filter(t => t.group === onlyGroup)
: parsed.teams;
if (targets.length === 0) {
return {
ok: false,
code: 'NO_TEACHER_IN_GROUP',
message: onlyGroup
? `报名表里没有属于「${onlyGroup}」组的指导教师行`
: '没有可回填的小组',
stats: parsed.stats,
warnings: parsed.errors,
};
}
const overwrite = options.overwrite !== false;
// 一次性取回本次涉及组别的队伍映射:key = group\u0000team_id
const groupSet = Array.from(new Set(targets.map(t => t.group)));
const teamRows = await ctx.model.Team.findAll({
attributes: ['team_uid', 'team_id', 'group_name'],
where: { group_name: { [Op.in]: groupSet } },
raw: true,
});
const teamKeyMap = new Map();
for (const t of teamRows) {
if (t.team_uid === null || t.team_uid === undefined) continue;
teamKeyMap.set(`${t.group_name}\u0000${t.team_id}`, t.team_uid);
}
const details = [];
const unmatched = [];
let matchedTeams = 0;
let updatedRows = 0;
let unchangedRows = 0;
let noMemberTeams = 0;
for (const t of targets) {
const teamUid = teamKeyMap.get(`${t.group}\u0000${t.teamId}`);
if (teamUid === undefined) {
unmatched.push({
group: t.group,
teamId: t.teamId,
reason: '本组 teams 表里没有该队伍编号(可能尚未导入选手,或编号写法不一致)',
});
continue;
}
matchedTeams++;
const teacherText = teacherBackfill.joinNames(t.names);
const members = await ctx.model.Contestant.findAll({
attributes: ['id', 'name', 'teacher_name'],
where: { team_id: teamUid, group: t.group },
raw: true,
});
if (members.length === 0) {
noMemberTeams++;
details.push({
group: t.group,
teamId: t.teamId,
teamUid,
teacherName: teacherText,
memberCount: 0,
affected: 0,
note: '该队伍在 contestants 里没有成员',
});
continue;
}
// 只更新「需要变」的行:overwrite=false 时仅填空缺
const needIds = [];
for (const m of members) {
const current = m.teacher_name === null || m.teacher_name === undefined ? '' : String(m.teacher_name);
if (!overwrite && current !== '') {
unchangedRows++;
continue;
}
if (current === teacherText) {
unchangedRows++;
continue;
}
needIds.push(m.id);
}
if (needIds.length > 0) {
// 🔴 只写 teacher_name 一列:显式 attributes 白名单,避免把整行对象
// 交给 update 时顺手带回 score / word_score 等无关列(Sequelize 会只更新
// 传入的键,但显式白名单能让后续 review 一眼看出写入面)。
await ctx.model.Contestant.update(
{ teacher_name: teacherText },
{ where: { id: { [Op.in]: needIds } } }
);
updatedRows += needIds.length;
}
details.push({
group: t.group,
teamId: t.teamId,
teamUid,
teacherName: teacherText,
memberCount: members.length,
affected: needIds.length,
rowNumbers: t.rowNumbers,
});
}
return {
ok: true,
code: 'OK',
message: `已回填 ${updatedRows} 名选手的指导教师(命中 ${matchedTeams} 个小组)`,
stats: parsed.stats,
teams: {
total: targets.length,
matched: matchedTeams,
unmatched: unmatched.length,
noMember: noMemberTeams,
},
updated: updatedRows,
unchanged: unchangedRows,
details,
unmatched,
// 教师行本身的问题(缺组别 / 缺队伍编号 / 姓名为空)——非致命,逐行透出
warnings: parsed.errors,
};
}
// ==================== 提交记录管理 ==================== // ==================== 提交记录管理 ====================
/** /**
@@ -746,7 +1015,10 @@ class AdminService extends Service {
const contestants = await ctx.model.Contestant.findAll({ const contestants = await ctx.model.Contestant.findAll({
where: { group }, where: { group },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score'], // R178:补 team_id —— 后台需要「小组编号」核对某小组是否整体完成考试。
// 组的归属只认 contestants.team_id(= teams.team_uid),⛔ 不可回落到
// aggregation.team_uid(生产实测两者不同号,会把不同小组混成一个)。
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'team_id'],
order: [['id', 'ASC']], order: [['id', 'ASC']],
}); });
@@ -787,6 +1059,28 @@ class AdminService extends Service {
const wordMap = {}; const wordMap = {};
examSubs.forEach(w => { wordMap[String(w.contestant_id)] = w; }); examSubs.forEach(w => { wordMap[String(w.contestant_id)] = w; });
// 🔴 R166 修复「已答 N / N 题」恒真显示:原 `totalQuestions: answeredCount`
// ⇒ 分子分母恒相等,该列零信息量(连「只答了 3 题」的考生也显示「已答 3 / 3 题」)。
// 真实分母 = 该考生被抽到的**总题数**,口径必须与抽题实现(service/exam.js:85-101)同源:
// 实抽 = min(配置抽取数, 题库实际容量) 逐题型相加
// ⚠️ 为什么不能直接用 choice_count + judge_count:那是**上限**,
// 题库不足时 `limit` 会被封顶(controller/exam.js:263-266 的 available* 就是为此暴露),
// 直接相加会把「实际 40 题」显示成「70 题」⇒ 分母大于真实题数,比恒真更误导。
// ⚠️ 为什么不能从逐题表反推总数:`subs.length` 只统计**已答**行,
// 未答的抽题不入表 ⇒ 反推出来仍是 answeredCount(同恒真)。
const [timeRow, availableChoice, availableJudge] = await Promise.all([
ctx.model.CompetitionTime.findOne({
where: { group },
attributes: ['choice_count', 'judge_count'],
raw: true,
}),
ctx.model.ChoiceQuestion.count({ where: { group } }),
ctx.model.JudgeQuestion.count({ where: { group } }),
]);
const totalQuestions =
Math.min(timeRow && timeRow.choice_count != null ? timeRow.choice_count : 50, availableChoice) +
Math.min(timeRow && timeRow.judge_count != null ? timeRow.judge_count : 20, availableJudge);
return contestants.map(c => { return contestants.map(c => {
// 归集键(含歧义消解后的兜底键);首元素必为主键 // 归集键(含歧义消解后的兜底键);首元素必为主键
const candidateKeys = aggKeysByContestant[String(c.id)] || [String(c.id)]; const candidateKeys = aggKeysByContestant[String(c.id)] || [String(c.id)];
@@ -795,10 +1089,17 @@ class AdminService extends Service {
const autoScore = subs.reduce((sum, s) => sum + (s.score || 0), 0); const autoScore = subs.reduce((sum, s) => sum + (s.score || 0), 0);
const wordSub = wordMap[String(c.id)]; const wordSub = wordMap[String(c.id)];
const hasWord = !!(wordSub && wordSub.word_file_path); const hasWord = !!(wordSub && wordSub.word_file_path);
// 正式提交状态:优先以 exam_submissions.submit_time 为准(与选手端 hasSubmitted 同一判定源); // 🔴 R178(B④)「已提交」口径拆分
// 历史数据/旧流程未回填 submit_time,但已有逐题答题记录(answeredCount>0)同样视为已提交, //
// 与 CSV 导出(answeredCount>0 -> 是)保持一致,避免「试卷管理恒显未提交」的错位。 // 旧写法:`submitted: !!formalSubmitTime || answeredCount > 0`
// ⇒ 只要答过一题就算「已提交」,没点交卷的考生在后台也显示「已提交」,
// 管理员无法据此判断谁真的交卷了;`submitTime` 还会退回首条答题行的
// created_at,把「第一次答题时间」显示成「提交时间」。
// 现在:submitted / formalSubmitted = 是否**正式交卷**,唯一判据 = exam_submissions.submit_time
// (与选手端 hasSubmitted 同一判定源);作答进度由 answeredCount / totalQuestions 单独表达。
const formalSubmitTime = (wordSub && wordSub.submit_time) || null; const formalSubmitTime = (wordSub && wordSub.submit_time) || null;
const formalSubmitted = !!formalSubmitTime;
const firstAnswerAt = subs.length ? subs[0].created_at : null;
return { return {
contestantId: c.id, contestantId: c.id,
aggregationUid: String(c.id), aggregationUid: String(c.id),
@@ -807,8 +1108,10 @@ class AdminService extends Service {
idNumber: c.id_number, idNumber: c.id_number,
phone: c.phone, phone: c.phone,
group: c.group, group: c.group,
answeredCount, // 小组编号(teams.team_uid);为空表示该选手未编入小组(导入时缺小队列的历史数据)
totalQuestions: answeredCount, teamId: c.team_id === null || c.team_id === undefined ? null : String(c.team_id),
answeredCount,
totalQuestions,
autoScore, autoScore,
wordScore: c.word_score ?? 0, wordScore: c.word_score ?? 0,
// 总分 = 客观题自动评分合计 + 综合大题阅卷分(与「卷面分 / 大题分」口径一致); // 总分 = 客观题自动评分合计 + 综合大题阅卷分(与「卷面分 / 大题分」口径一致);
@@ -817,8 +1120,10 @@ class AdminService extends Service {
hasWordFile: hasWord, hasWordFile: hasWord,
wordFilePath: (wordSub && wordSub.word_file_path) || null, wordFilePath: (wordSub && wordSub.word_file_path) || null,
wordFileName: (wordSub && wordSub.word_file_name) || null, wordFileName: (wordSub && wordSub.word_file_name) || null,
submitted: !!formalSubmitTime || answeredCount > 0, submitted: formalSubmitted,
submitTime: formalSubmitTime || (subs.length > 0 ? subs[0].created_at : null), formalSubmitted,
firstAnswerAt,
submitTime: formalSubmitTime,
// 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致。 // 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致。
// ⚠️ 此前本行与上方 `examScore: c.exam_score ?? 0` 重复定义(对象字面量重复键,后者静默胜出); // ⚠️ 此前本行与上方 `examScore: c.exam_score ?? 0` 重复定义(对象字面量重复键,后者静默胜出);
// 虽因 updateScores 会同步写 exam_score 而「结果碰巧一致」,但属隐患,故删除上方那条只留本条。 // 虽因 updateScores 会同步写 exam_score 而「结果碰巧一致」,但属隐患,故删除上方那条只留本条。
@@ -1002,13 +1307,17 @@ class AdminService extends Service {
// 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。 // 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
const submissions = await this.getExamSubmissions(group); const submissions = await this.getExamSubmissions(group);
return { return {
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '客观题分', '大题分', '总分'], // 🔴 R178(B④):原「是否提交」列取 `answeredCount > 0`,与页面/接口口径(submit_time)
// 不一致,同一份数据导出与页面显示会互相矛盾。现在拆为「是否交卷」(submit_time)
// 与「答题数」两列,口径与 getExamSubmissions 的 submitted 完全同源。
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否交卷', '答题数', '客观题分', '大题分', '总分'],
rows: submissions.map(item => [ rows: submissions.map(item => [
item.contestantId, item.contestantId,
item.idNumber || '', item.idNumber || '',
item.userName || '', item.userName || '',
item.phone || '', item.phone || '',
item.answeredCount > 0 ? '是' : '否', item.submitted ? '是' : '否',
item.answeredCount ?? 0,
item.examScore ?? 0, item.examScore ?? 0,
item.wordScore ?? 0, item.wordScore ?? 0,
item.totalScore ?? 0, item.totalScore ?? 0,
@@ -1085,8 +1394,199 @@ class AdminService extends Service {
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
} }
// ==================== 批量下载 ZIP ==================== /**
* 成绩公示表导出(R176)
*
* 产物 = 赛事官方参考模板(`lib/assets/publicity-template.xlsx`)的 xlsx 半成品:
* 数据行已填,**获奖等级留空**待人工填。
*
* 行粒度(用户裁定):**一行 = 一个小组**。系统规则是「一组一人参赛、成绩全组共享」,
* 所以同一小组不会因为多人而占多行;`参赛选手` / `指导教师` 两列用「、」连接全体。
* 没有队伍归属的选手(`team_id IS NULL`,单人参赛)各自一行。
*
* 🔴 `contestants.score` 只在「组内唯一抽题者」名下非空(见 score.js 的共享读),
* 因此按组取 **MAX(score)**,⛔ 不能取第一行 —— 否则会取到 NULL。
* 🔴 单位取 `aggregation.organization_cn`(`account_uid` = 选手 id),
* 组内取首个非空值(同队理论上相同)。
* 🔴 本表**不含**身份证号 / 手机号,可直接对外;内部核查请用 exportToXlsx。
*
* @param {string} group
* @returns {Promise<{buffer: Buffer, fileName: string, rowCount: number, testCount: number, title: Object}>}
*/
async buildPublicityXlsx(group) {
const { ctx } = this;
const publicitySheet = require('../lib/publicitySheet');
const fs = require('fs');
const path = require('path');
const { Op } = ctx.app.Sequelize;
const GROUP_LABEL = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
const groupLabel = GROUP_LABEL[group] || group;
// ---- 1) 赛项名称 / 编号(管理后台「赛项设置」维护的两项)----
let itemName = '';
let itemCode = '';
try {
const cfgRows = await ctx.model.CompetitionInfo.findAll({
attributes: ['info_key', 'info_value'],
where: { info_key: { [Op.in]: ['competition_item_name', 'competition_item_code'] } },
raw: true,
});
for (const r of cfgRows) {
const v = r.info_value === null || r.info_value === undefined ? '' : String(r.info_value).trim();
if (r.info_key === 'competition_item_name') itemName = v;
if (r.info_key === 'competition_item_code') itemCode = v;
}
} catch (e) {
// 配置缺失不应阻断导出:标题保持模板占位(***赛项 / 空编号),人工再补
ctx.logger.warn('[publicity] 读取赛项配置失败,标题使用模板占位:%s', e.message);
}
// ---- 2) 选手(⛔ 不筛 test_flag:与 exportToXlsx 口径一致;测试行数单独统计给前端提示)----
const contestants = await ctx.model.Contestant.findAll({
attributes: ['id', 'name', 'score', 'team_id', 'teacher_name', 'test_flag'],
where: { group },
raw: true,
});
// ---- 3) 队伍编号(报名表里的「队伍编号」落在 teams.team_id)----
const teams = await ctx.model.Team.findAll({
attributes: ['team_uid', 'team_id'],
where: { group_name: group },
raw: true,
});
const teamIdByUid = new Map();
for (const t of teams) {
if (t.team_uid !== null && t.team_uid !== undefined) teamIdByUid.set(t.team_uid, t.team_id);
}
// ---- 4) 参赛单位 ----
const aggRows = await ctx.model.Aggregation.findAll({
attributes: ['account_uid', 'organization_cn'],
where: { group },
raw: true,
});
const orgByCid = new Map();
for (const a of aggRows) {
const v = a.organization_cn === null || a.organization_cn === undefined ? '' : String(a.organization_cn).trim();
if (v && a.account_uid !== null && a.account_uid !== undefined) orgByCid.set(a.account_uid, v);
}
// ---- 5) 按小组聚合 ----
const bucket = new Map();
let testCount = 0;
let noTeamCount = 0;
for (const c of contestants) {
if (c.test_flag === 1) testCount++;
const hasTeam = c.team_id !== null && c.team_id !== undefined;
// 🔴 组归属只认 contestants.team_id(= teams.team_uid)——这是系统里唯一可靠的键。
// ⛔ 不要回落到 aggregation.team_uid:那是另一套编号(实测本地 zhongzhi 的
// aggregation.team_uid=1 而 teams.team_uid=8),拿它归组会把不同队伍并到一起。
// 无 team_id 的选手按「单人参赛」各自成行,并把人数透出给前端提示
// —— 覆盖率不足是数据问题,⛔ 不能在导出时静默猜。
if (!hasTeam) noTeamCount++;
const key = hasTeam ? `team:${c.team_id}` : `single:${c.id}`;
let item = bucket.get(key);
if (!item) {
item = {
teamUid: hasTeam ? c.team_id : null,
teamId: hasTeam ? String(teamIdByUid.get(c.team_id) || c.team_id) : '',
org: '',
players: [],
teachers: [],
score: null,
};
bucket.set(key, item);
}
if (c.name) item.players.push(String(c.name).trim());
// teacher_name 是「、」连接的整串,拆开交给 buildPublicityRows 统一去重拼接
if (c.teacher_name) {
for (const n of String(c.teacher_name).split('、')) {
const s = n.trim();
if (s) item.teachers.push(s);
}
}
if (c.score !== null && c.score !== undefined) {
const s = Number(c.score);
if (isFinite(s) && (item.score === null || s > item.score)) item.score = s;
}
const org = orgByCid.get(c.id);
if (org && !item.org) item.org = org;
}
const rows = publicitySheet.buildPublicityRows(Array.from(bucket.values()));
// ---- 6) 套模板 ----
const tplPath = path.join(__dirname, '../lib/assets/publicity-template.xlsx');
const template = fs.readFileSync(tplPath);
const built = publicitySheet.buildXlsx(template, rows, { itemName, itemCode });
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const dateTag = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
return {
buffer: built.buffer,
fileName: `成绩公示表-${groupLabel}-${dateTag}.xlsx`,
rowCount: rows.length,
testCount,
noTeamCount,
totalContestants: contestants.length,
title: built.title,
// 赛项名/编号为空时前端要提示「标题仍是模板占位」
itemName,
itemCode,
};
}
// ==================== 赛项信息配置(R176) ====================
/** 成绩公示表标题要用的两项配置。读:`competition_info` 的 key-value 行 */
async getCompetitionItemInfo() {
const { ctx } = this;
const { Op } = ctx.app.Sequelize;
const out = { itemName: '', itemCode: '' };
const rows = await ctx.model.CompetitionInfo.findAll({
attributes: ['info_key', 'info_value'],
where: { info_key: { [Op.in]: ['competition_item_name', 'competition_item_code'] } },
raw: true,
});
for (const r of rows) {
const v = r.info_value === null || r.info_value === undefined ? '' : String(r.info_value);
if (r.info_key === 'competition_item_name') out.itemName = v;
if (r.info_key === 'competition_item_code') out.itemCode = v;
}
return out;
}
/**
* 保存赛项信息。
* 🔴 用 upsert 语义而非「先删后插」:`competition_info` 的 id 是自增主键,
* 删了重建会让 id 漂移,且若中途失败会留下半张表。
* @param {{itemName?: string, itemCode?: string}} payload
*/
async setCompetitionItemInfo(payload = {}) {
const { ctx } = this;
const pairs = [
['competition_item_name', payload.itemName],
['competition_item_code', payload.itemCode],
];
const saved = {};
for (const [key, raw] of pairs) {
if (raw === undefined) continue; // 未提交的键不动
const value = raw === null ? '' : String(raw).trim();
const exist = await ctx.model.CompetitionInfo.findOne({ where: { info_key: key } });
if (exist) {
await exist.update({ info_value: value });
} else {
await ctx.model.CompetitionInfo.create({ info_key: key, info_value: value });
}
saved[key] = value;
}
return saved;
}
// ==================== 批量下载 ZIP ====================
/** /**
* 批量下载某组所有提交文件,打包为 ZIP * 批量下载某组所有提交文件,打包为 ZIP
* 文件重命名为 "组号-学校-姓名-原文件名" 格式 * 文件重命名为 "组号-学校-姓名-原文件名" 格式
@@ -1584,6 +2084,28 @@ class AdminService extends Service {
choice_count: choiceCount, choice_count: choiceCount,
judge_count: judgeCount, judge_count: judgeCount,
}); });
// ---- 节点备份重排(考试开始前 30 分钟 / 考试结束后 10 分钟)----
// 🔴 R167:管理员每次保存/修改考试时间都要重排该组的两个时间节点。
// · 幂等键含 fireAt ⇒ 改时间 = 新计划(该再备一次);重复点保存同一时间
// = 同一 fireAt = 覆盖同一计划(不重复触发)。
// · ⛔ 备份失败绝不影响本方法返回 —— 定时器是「到点才跑」,此处只排程,
// 且调度器内部对 enabled=false 直接返回(非生产不排)。
try {
const scheduler = this.app.nodeBackupScheduler;
if (scheduler && scheduler.enabled) {
scheduler.rescheduleGroupTimes([{
group,
start_time: startTime ? new Date(startTime) : null,
end_time: endTime ? new Date(endTime) : null,
}]);
}
} catch (bkErr) {
this.ctx.logger.error(
'[setTimes] 节点备份重排失败(不影响时间设置):group=%s err=%s',
group, bkErr && bkErr.message
);
}
} }
} }
@@ -1641,7 +2163,7 @@ class AdminService extends Service {
const isExam = group === 'zhongzhi' || group === 'gaozhi'; const isExam = group === 'zhongzhi' || group === 'gaozhi';
const contestants = await ctx.model.Contestant.findAll({ const contestants = await ctx.model.Contestant.findAll({
where: { group }, where: { group },
attributes: ['id', 'name', 'id_number', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'], attributes: ['id', 'name', 'id_number', 'group', 'score', 'exam_score', 'word_score', 'ppt_score', 'team_id'],
order: [['id', 'ASC']], order: [['id', 'ASC']],
raw: true, raw: true,
}); });
@@ -1670,6 +2192,17 @@ class AdminService extends Service {
pptScore: c.ppt_score ?? 0, pptScore: c.ppt_score ?? 0,
})); }));
// R178:小组编号(与「选手管理」同源口径,见模块级 resolveTeamLabels 注释)。
// 前端按 teamId 渲染;teamId 为 null ≠ 「没有小组」的权威判据,仅表示「无队伍元信息」,
// 因此另附 teamUid 便于排查「有归属但缺 teams 行」的历史数据。
const teamLabels = await resolveTeamLabels(ctx, contestants);
list.forEach(item => {
const t = teamLabels.get(String(item.uid)) || null;
item.teamUid = t ? t.teamUid : null;
item.teamId = t ? t.teamId : null;
item.teamNumber = t ? t.teamNumber : null;
});
if (isExam) { if (isExam) {
const SubmissionModel = group === 'zhongzhi' const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission ? ctx.model.ZhongzhiSubmission
@@ -1708,9 +2241,13 @@ class AdminService extends Service {
item.score = autoScore + item.wordScore; item.score = autoScore + item.wordScore;
const es = examMap[String(item.uid)]; const es = examMap[String(item.uid)];
item.hasWordFile = !!(es && es.word_file_path); item.hasWordFile = !!(es && es.word_file_path);
item.submitted = !!(es && es.submit_time) || answeredCount > 0; // 🔴 R178(B④)与「考生答题记录」同一口径:submitted = 是否**正式交卷**
item.submitTime = (es && es.submit_time) //(唯一判据 exam_submissions.submit_time);作答进度看 answeredCount。
|| (answeredCount > 0 ? (es && es.created_at) || null : null); // ⛔ 不再用 es.created_at(进考场建占位行的时间)冒充 submitTime。
item.formalSubmitted = !!(es && es.submit_time);
item.submitted = item.formalSubmitted;
item.submitTime = (es && es.submit_time) || null;
item.firstAnswerAt = answeredCount > 0 ? (es && es.created_at) || null : null;
}); });
} else { } else {
// 本科/教师组:上传作品即视为已提交(与考生端 exam.status.submitted、作品管理口径一致)。 // 本科/教师组:上传作品即视为已提交(与考生端 exam.status.submitted、作品管理口径一致)。
@@ -1970,34 +2507,10 @@ class AdminService extends Service {
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
} }
// ==================== 新闻查询 ==================== // R177:删掉此处一个**死代码版** `listNews()` —— 它无分页、缺 is_draft/附件字段、
// 排序按 sort_order,且在同一个 class 内被下方同名方法覆盖(JS class 后定义胜出)⇒ 永远不执行。
/** // ⚠️ 新闻列表的唯一生效实现 = 本文件下方的 `async listNews(query)`(分页 + 含 is_draft/附件字段)。
* 获取所有新闻列表(管理员视图,包含未发布) // 保留这行说明是为了防止后人「以为这里还有一份实现」而改到静默无效的位置。
* @returns {Promise<Array>} 新闻列表
*/
async listNews() {
const { ctx } = this;
const news = await ctx.model.News.findAll({
order: [['pinned', 'DESC'], ['sort_order', 'ASC'], ['publish_time', 'DESC']],
raw: true,
});
return news.map(n => ({
id: n.id,
title: n.title,
content: n.content,
news_type: n.news_type,
wx_link: n.wx_link,
publish_time: n.publish_time,
pinned: n.pinned,
group: n.group,
tags: n.tags,
sort_order: n.sort_order,
created_at: n.created_at,
updated_at: n.updated_at,
}));
}
// ==================== 轮播图查询 ==================== // ==================== 轮播图查询 ====================
@@ -2107,6 +2620,115 @@ class AdminService extends Service {
if (!group) { if (!group) {
await this._setConfig('score_published', value, transaction); await this._setConfig('score_published', value, transaction);
} }
// ---- 节点备份:成绩「发布」后立即触发(撤销不触发)----
// ⚠️ 用户裁定③:撤销发布时备份到的是**已发布过的数据**,对回退诉求无意义 ⇒
// 只在 publish === true 时触发。⛔ 必须排在事务之外(此处 transaction 已提交),
// 否则备份 dump 到的可能是未提交快照。
// ⛔ 备份失败不得让「发布成绩」这个管理动作失败 ⇒ 独立 try/catch 只记日志。
if (publish) {
try {
const scheduler = this.app.nodeBackupScheduler;
if (scheduler && scheduler.enabled) {
// ⛔ await 但内部为 spawn 异步 + 不抛错 ⇒ 只多花一次 spawn 的时间
scheduler.triggerScorePublished(group).catch(bkErr => {
this.ctx.logger.error('[publishScores] 节点备份触发失败:%s', bkErr && bkErr.message);
});
}
} catch (bkErr) {
this.ctx.logger.error('[publishScores] 节点备份触发失败(不影响发布):%s', bkErr && bkErr.message);
}
// ---- 成绩公示预览新闻(R176)----
// 用户裁定:成绩发布后直接生成「包含附件的预览新闻」,等人工编辑补齐获奖等级后再发布。
// 🔴 必须是**草稿**(is_draft=1):news.list / findById 都会过滤 is_draft=1,
// 否则未定稿的成绩会立刻出现在前台新闻列表 ⇒ 等于提前公示错误成绩。
// ⛔ 与节点备份同理:生成失败不得让「发布成绩」这个动作失败。
try {
const targets = group ? [ group ] : this.constructor.PUBLISH_GROUPS;
await this._ensurePublicityDraft(targets);
} catch (pubErr) {
this.ctx.logger.error('[publishScores] 生成公示预览新闻失败(不影响发布):%s', pubErr && pubErr.message);
}
}
}
/**
* 为指定组别生成「成绩公示」草稿新闻(R176)。
*
* 幂等:同组已有草稿(tags = 'publicity-draft' 且 is_draft=1)时**覆盖**其附件与正文,
* 不新增第二条 —— 反复发布/撤销再发布会堆出一串重复新闻。
* 覆盖时旧附件文件要一并清理,否则 `app/public/news` 会随发布次数线性膨胀。
*
* @param {string[]} groups
* @returns {Promise<Array<Object>>} 每组的结果摘要(仅供日志/排查)
*/
async _ensurePublicityDraft(groups) {
const { ctx } = this;
const fs = require('fs');
const path = require('path');
const GROUP_LABEL = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
const DRAFT_TAG = 'publicity-draft';
const results = [];
for (const g of groups) {
try {
const built = await this.buildPublicityXlsx(g);
const dir = path.join(this.app.baseDir, 'app/public/news');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const storedName = `news_file_publicity_${g}_${Date.now()}.xlsx`;
fs.writeFileSync(path.join(dir, storedName), built.buffer);
const fileUrl = `/public/news/${storedName}`;
const label = GROUP_LABEL[g] || g;
const title = `${label}成绩公示(待审核)`;
const content = [
`<p>本公示表按成绩管理数据自动生成,<strong>尚未定稿</strong>,请核对数据并补齐「获奖等级」后再对外发布。</p>`,
'<p>填表说明:一行代表一个参赛小组;「参赛选手」「指导教师」列多人时以顿号分隔;',
'总分数按由高到低排序,「成绩排名」同序排列。</p>',
`<p>本次导出共 ${built.rowCount} 个小组${built.testCount ? `(其中 ${built.testCount} 人属于测试数据,发布前请确认是否需要剔除)` : ''}。</p>`,
].join('');
const exist = await ctx.model.News.findOne({ where: { group: g, is_draft: 1, tags: DRAFT_TAG } });
if (exist) {
const oldUrl = exist.attachment_url;
await exist.update({
title,
content,
news_type: 'content',
attachment_url: fileUrl,
attachment_name: built.fileName,
});
// 旧附件清理:只删本流程自己产出的文件(前缀 + 目录双重限定,绝不误删人工上传的图/文件)
if (oldUrl && oldUrl !== fileUrl && oldUrl.indexOf('/public/news/news_file_publicity_') === 0) {
ctx.helper.safeCleanup(path.join(this.app.baseDir, 'app', oldUrl.replace(/^\//, '')), {
label: 'publicity-draft-old',
});
}
results.push({ group: g, newsId: exist.id, action: 'updated', fileUrl });
} else {
const created = await this.createNews({
title,
content,
news_type: 'content',
group: g,
tags: DRAFT_TAG,
is_draft: 1,
publish_time: new Date(),
attachment_url: fileUrl,
attachment_name: built.fileName,
});
results.push({ group: g, newsId: created.id, action: 'created', fileUrl });
}
} catch (e) {
ctx.logger.error('[publicity] 组 %s 生成公示预览失败:%s', g, e.message);
results.push({ group: g, action: 'error', message: e.message });
}
}
return results;
} }
/** /**
@@ -2172,6 +2794,11 @@ class AdminService extends Service {
pinned: newsData.pinned || false, pinned: newsData.pinned || false,
group: newsData.group || newsData.group_name || 'all', group: newsData.group || newsData.group_name || 'all',
tags: newsData.tags || null, tags: newsData.tags || null,
// R176:草稿标记与附件。缺省 is_draft=0(已发布)保持既有调用方行为不变;
// 成绩发布自动生成的预览新闻会显式传 1,人工编辑确认后再置 0。
is_draft: newsData.is_draft ? 1 : 0,
attachment_url: newsData.attachment_url || null,
attachment_name: newsData.attachment_name || null,
}); });
} }
@@ -2194,6 +2821,10 @@ class AdminService extends Service {
pinned: newsData.pinned !== undefined ? newsData.pinned : news.pinned, pinned: newsData.pinned !== undefined ? newsData.pinned : news.pinned,
group: newsData.group !== undefined ? newsData.group : (newsData.group_name !== undefined ? newsData.group_name : news.group), group: newsData.group !== undefined ? newsData.group : (newsData.group_name !== undefined ? newsData.group_name : news.group),
tags: newsData.tags !== undefined ? newsData.tags : news.tags, tags: newsData.tags !== undefined ? newsData.tags : news.tags,
// R176:附件与草稿。人工在新闻管理里「发布」= 把 is_draft 置 0。
is_draft: newsData.is_draft !== undefined ? (newsData.is_draft ? 1 : 0) : news.is_draft,
attachment_url: newsData.attachment_url !== undefined ? (newsData.attachment_url || null) : news.attachment_url,
attachment_name: newsData.attachment_name !== undefined ? (newsData.attachment_name || null) : news.attachment_name,
}); });
return news; return news;

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 完全一致)。 // `@e965/xlsx`(0.20.3,含两处修复,零依赖,API 与 xlsx 完全一致)。
const XLSX = require('@e965/xlsx'); const XLSX = require('@e965/xlsx');
// 成绩导入的**纯判定层**(R172 建 / R174 改三小问四列制):小问取值校验 / 逐行判定 / 表头别名解析。
// ⚠️ 必须走这个 lib,不得在本文件内联第二套算法 —— 否则 `--self-test` 自证的是副本,
// 主流程仍可能是错的(恒绿假自证,R167 M1 血证)。
const bigScoreImport = require('../lib/bigScoreImport');
/** /**
* 综合大题服务 - BigQuestionService * 综合大题服务 - BigQuestionService
* *
@@ -337,15 +342,24 @@ class BigQuestionService extends Service {
order: [['id', 'ASC']], 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 => { return contestants.map(c => {
const rec = byContestant[String(c.id)] || byAgg[String(c.id)] || null; const rec = byContestant[String(c.id)] || byAgg[String(c.id)] || null;
const tm = teamMeta.byContestant[String(c.id)] || null;
return { return {
contestantId: c.id, contestantId: c.id,
aggregationUid: rec ? rec.aggregation_uid : c.id, aggregationUid: rec ? rec.aggregation_uid : c.id,
name: c.name, name: c.name,
idNumber: c.id_number, idNumber: c.id_number,
phone: c.phone, phone: c.phone,
// teamId 保留为**整数队伍号**(历史字段,勿改语义);teamNumber 才是人类可读的小组编号
teamId: c.team_id, teamId: c.team_id,
teamUid: tm ? tm.teamUid : null,
teamNumber: tm ? tm.teamNumber : null,
drawn: !!rec, drawn: !!rec,
bigQuestionUid: rec ? rec.big_question_uid : null, bigQuestionUid: rec ? rec.big_question_uid : null,
questionSnapshot: rec ? this.parseSnapshot(rec.question_snapshot) : 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; 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 }], runs: [{ text: `${groupLabel}综合大题阅卷表`, bold: true, size: 32 }],
}); });
blocks.push({ type: 'p', runs: [{ text: `姓名:${s.name || '--'}  身份证号:${s.idNumber || '--'}  手机号:${s.phone || '--'}` }] }); blocks.push({ type: 'p', runs: [{ text: `姓名:${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: `抽到题目:${snap.title || '--'}(${s.bigQuestionUid || '--'})` }] });
blocks.push({ type: 'p', runs: [{ text: `提交时间:${fmtTime(s.submitTime)}  已答小问:${s.answeredCount} / ${SUB_QUESTION_COUNT}` }] }); 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; 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 * @param {number} aggregationUid - 聚合报名 ID
@@ -412,6 +459,12 @@ class ExamService extends Service {
async saveAnswers(aggregationUid, answers, group) { async saveAnswers(aggregationUid, answers, group) {
const { ctx } = this; const { ctx } = this;
// 交卷冻结兜底:所有写答案的路径都经此函数,故此处是本守卫的最后一道闸。
// 不会挡住交卷自身 —— submitAll 是「先 saveAnswers、后 ensureExamSubmission 写
// submit_time」,执行到本函数时 submit_time 仍为 NULL;整卷重交在更早的 4006 已返回;
// settleOnly 结算通道刻意不调用本函数。
await this.assertNotSubmitted(aggregationUid);
// 根据组别选择对应的提交模型 // 根据组别选择对应的提交模型
const SubmissionModel = group === 'zhongzhi' const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission ? ctx.model.ZhongzhiSubmission

View File

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

View File

@@ -291,10 +291,13 @@ class UploadService extends Service {
score: null, // 重新上传后清除评分 score: null, // 重新上传后清除评分
}); });
if (stalePath) { if (stalePath) {
// best-effort:删不掉只告警,绝不影响「上传成功」这个业务结果 // best-effort:删不掉只告警,绝不影响「上传成功」这个业务结果
const r = this.removePublicFile(stalePath, 'overwrite-stale'); const r = this.removePublicFile(stalePath, 'overwrite-stale');
if (r !== 'removed') { // 🔴 R157:登记计数(⛔ 不抛错,保持 best-effort),使「旧文件清理失败」
// 在运行期可被读取,而不只沉在日志里。
this._noteDeleteResult(r, stalePath, 'overwrite-stale');
if (r !== 'removed') {
this.app.logger.warn( this.app.logger.warn(
`[saveSubmissionRecord] 旧文件未能清理,已成为孤儿(等待兜底扫描 script/scan_orphan_files.py): ${stalePath} -> ${r}` `[saveSubmissionRecord] 旧文件未能清理,已成为孤儿(等待兜底扫描 script/scan_orphan_files.py): ${stalePath} -> ${r}`
); );
@@ -374,8 +377,14 @@ class UploadService extends Service {
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx) // 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
// 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径, // 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径,
// 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。 // 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。
//
// 🔴 R157:删除结果**不再丢弃**。此前返回值被完全忽略 ⇒ 删除失败只进日志,
// 运行期无计数、无暴露,孤儿文件在服务侧完全不可见(只能靠离线扫描发现,
// 而离线扫描报出的孤儿无法回溯「是哪一次删除失败的」)。
// 修法:累计到**服务实例计数器 + 暴露 getter**,⛔ 不抛错(保持 best-effort 语义)。
if (record.file_path) { 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(); await record.destroy();
@@ -391,6 +400,7 @@ class UploadService extends Service {
* 一旦删除失败就静默留下一份无人认领的文件(孤儿)。 * 一旦删除失败就静默留下一份无人认领的文件(孤儿)。
* 现收敛为**唯一入口**:所有「按 DB 里的相对路径删文件」都必须经过这里。 * 现收敛为**唯一入口**:所有「按 DB 里的相对路径删文件」都必须经过这里。
* *
/**
* 【安全闸门】只允许删除 `works/` 与 `tmp/` 前缀下的文件: * 【安全闸门】只允许删除 `works/` 与 `tmp/` 前缀下的文件:
* - DB 里的 file_path 天然是 `works/<group>/<file>`;前缀白名单可挡住 * - DB 里的 file_path 天然是 `works/<group>/<file>`;前缀白名单可挡住
* 「脏数据 / 手工改库 / SQL 注入」把 `file_path` 指向 `templates/`、`carousel/` * 「脏数据 / 手工改库 / SQL 注入」把 `file_path` 指向 `templates/`、`carousel/`
@@ -404,6 +414,48 @@ class UploadService extends Service {
* @param {string} [label] - 日志场景名 * @param {string} [label] - 日志场景名
* @returns {'removed'|'missing'|'skipped'} 删除结果(不抛错) * @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') { removePublicFile(relativePath, label = 'removePublicFile') {
const { ctx, app } = this; const { ctx, app } = this;
const logger = (ctx && ctx.logger) || app.logger; const logger = (ctx && ctx.logger) || app.logger;

View File

@@ -499,6 +499,71 @@ module.exports = (appInfo) => {
maxDays: 14, maxDays: 14,
}; };
// -----------------------------------------------------------------
// 节点备份调度(考试开始前 / 考试结束后 / 成绩发布后)
// -----------------------------------------------------------------
// 需求来源:用户 2026-10-16「系统在考试开始前,考试结束后,成绩发布后这三个
// 节点应该执行数据备份」。触发层用**后端钩子 + Node 定时器**(用户裁定①:
// 巡检脚本会占额外内存),实现见 app/lib/nodeBackupScheduler.js。
//
// 🔴 生产隔离:备份目标是**生产库**(用户裁定②)⇒ 本段仅在生产环境生效,
// 非生产 enabled=false 且调度器构造时即封死。这是与上方
// DEPLOY_ENV_LOCAL_SKIP_PREFIXES=['DB_',...] 同一纪律的延伸:
// 非生产进程不得持有生产连接凭据、不得去 dump 生产库。
//
// ⛔ 与 scripts/backup_daily.sh 的分工:那个脚本被自身硬门禁锁死在**本地库**
// (端口须 ∈ {3306,3307,33060}、BACKUP_ENV_FILE 匹配 *production* 一律拒绝),
// 故本节点备份**不复用它的入口**,只复用它底层的 scripts/backup_db.py
// (同一份 mysqldump + gzip + 完整性校验 + 保留期治理,避免两套校验口径漂移)。
// ⛔ 绝不「放宽 backup_daily.sh 的门禁来复用」—— 那是把本地防线拆给生产用。
// 🔴 路径纪律:生产机的目录布局**与本地仓库不一致**,⛔ 不许用 `__dirname` 推导
// (R168 实测踩坑)。取证依据(2026-08-10 / 2026-09-24 生产实证):
// 后端 baseDir = /home/root/production/agent-code/back-end/competition-website
// (注意是 `back-end` 连字符,不是仓库里的 `backend`)
// 脚本目录 = /home/root/production/agent-code/scripts/ ← 与 deploy/ 同级
// 环境文件 = /home/root/production/agent-code/deploy/competition-website/.env.production
// (systemd EnvironmentFile,见 deploy/competition-backend.service)
// ⇒ `repoRoot = resolve(__dirname,'../..')` 在本地指向仓库根、在生产指向
// `.../agent-code/back-end` ⇒ `join(root,'deploy','competition-website','scripts')`
// 在生产解析成 `.../back-end/deploy/competition-website/scripts` =**不存在**。
// 解法 = 全部走环境变量、可显式覆盖,默认值只覆盖本地开发:
// BACKUP_SCRIPT / BACKUP_ENV_FILE / BACKUP_OUT_DIR / BACKUP_LOG_DIR。
// ⚠️ 这四项须在 deploy/.env.production 中显式声明(部署检查清单会逐条核对),
// 否则生产会落到下面的默认值 ⇒ **路径错 ⇒ 备份静默失败**(P1 级)。
const nbPath = require('path');
const localRepo = nbPath.resolve(__dirname, '..', '..');
config.nodeBackup = {
enabled: true,
// 触发缓冲(用户裁定①的「留出缓冲」口径)
beforeStartBufferMin: Number(process.env.BACKUP_BEFORE_START_MIN) || 30, // 考试开始前 30 分钟
afterEndDelayMin: Number(process.env.BACKUP_AFTER_END_MIN) || 10, // 考试结束后 10 分钟
// 🔴 备份脚本路径:⛔ 本地默认值在生产不存在 ⇒ 生产必须由 BACKUP_SCRIPT 覆盖
scriptPath: process.env.BACKUP_SCRIPT
|| nbPath.join(localRepo, 'scripts', 'backup_db.py'),
// 环境文件(生产库凭据来源)
envFile: process.env.BACKUP_ENV_FILE
|| nbPath.join(localRepo, 'deploy', '.env.production'),
// 节点备份独立目录:⛔ 不复用 backups/db —— 否则会被每日备份的 keep-days
// 清理误删(每日 14 天 vs 节点备份需长期留存以备「赛后复盘/申诉」回溯)
outDir: process.env.BACKUP_OUT_DIR
|| nbPath.join(localRepo, 'backups', 'db-nodes'),
logDir: process.env.BACKUP_LOG_DIR
|| nbPath.join(localRepo, 'logs', 'backup', 'nodes'),
keepDays: Number(process.env.BACKUP_KEEP_DAYS) || 90,
// 跨进程锁 TTL(秒):残留锁(kill -9)会永久挡住后续备份 ⇒ 需能接管。
// 取 2h:远大于一次 dump 的正常耗时(分钟级),又不会让崩溃残留锁永久生效。
lockTtlSec: Number(process.env.BACKUP_LOCK_TTL_SEC) || 7200,
// 服务器 Py 3.6.8 无 pymysql/openpyxl ⇒ 既有约定走 /opt/pytools/lib
pythonPath: process.env.BACKUP_PYTHON_PATH || '/opt/pytools/lib',
mysqldump: process.env.BACKUP_MYSQLDUMP || '',
// ⚠️ spawn 的 cwd:只影响相对路径解析,backup_db.py 内部路径均基于 __dirname
repoRoot: process.env.BACKUP_CWD || localRepo,
};
// 非生产强制关闭(即使误设 enabled=true 也不生效)
if (!config.isProduction) {
config.nodeBackup.enabled = false;
}
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// 生产环境安全守卫:禁止使用内置默认密钥上线 // 生产环境安全守卫:禁止使用内置默认密钥上线
// (由 APP_ENV=production 触发) // (由 APP_ENV=production 触发)

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