Compare commits
11 Commits
6d3fbd62b4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bca48369f | |||
| c15b46a0f9 | |||
| 78f34ea9f5 | |||
| 0a380371ec | |||
| de719d8ec6 | |||
| 459f1adf6d | |||
| 5392345078 | |||
| 192b43bc67 | |||
| 14a110eb5f | |||
| 4f78995f1c | |||
| ae436c01ca |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -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
23
app.js
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ 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');
|
||||||
|
// 🔴 R195:下载文件名的「小组编号」前缀与拼接规则唯一 SoT(与落盘目录名同源)。
|
||||||
|
// 中职/高职大题单个下载与批量 ZIP 包内名**必须**共用 buildTeamDownloadName。
|
||||||
|
const { buildTeamDownloadName } = require('../lib/team_folder');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理控制器 - AdminController
|
* 管理控制器 - AdminController
|
||||||
@@ -163,6 +168,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 +210,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 +227,235 @@ 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 : '导出成绩公示表失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成绩排名表导出(R220)
|
||||||
|
* GET /api/admin/export-score-rank/:group
|
||||||
|
*
|
||||||
|
* 一行 = 一个参赛小组,按小组**客观题分**降序**全量**导出(不截断 80 个),
|
||||||
|
* 整行按客观题分区间上底色(≥60 绿 / 50–59.99 黄 / <50 红),
|
||||||
|
* 表尾给三区间小组数统计(全量 + 全量排名前 80)。
|
||||||
|
*
|
||||||
|
* ⛔ 与 exportExcel 用途不同:那个是内部核查表(含身份证号 / 手机号);
|
||||||
|
* 本表只有名次 / 小组编号 / 学校 / 姓名 / 客观题分,可直接给评审看。
|
||||||
|
* 🔴 R220-C 用户裁定:本表按**客观题分**筛选 ⇒ 只出 6 列,「大题分」「总分」不出现;
|
||||||
|
* 排序键 / 并列判据 / 区间底色三者同源,均为客观题分。
|
||||||
|
* ⚠️ 客观题分仍走「实时口径」(与成绩管理页面同源),⛔ 不读 contestants.score。
|
||||||
|
*/
|
||||||
|
async exportScoreRank() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
// 第二参数 false = 只允许考试组(中职 / 高职)
|
||||||
|
if (!this._validateGroup(group, false)) return;
|
||||||
|
|
||||||
|
const built = await service.admin.buildScoreRankXlsx(group);
|
||||||
|
|
||||||
|
// `?meta=1` 只回统计、不下载:前端在点「导出」前用它提示
|
||||||
|
// 「本组 N 人无小组归属」「底色生成失败已降级」这类需要管理员知情的事实。
|
||||||
|
// ⚠️ 不能靠自定义响应头带这些字段 —— 生产经 X-Accel 时上游自定义头会被丢弃。
|
||||||
|
if (String(ctx.query.meta || '') === '1') {
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
fileName: built.fileName,
|
||||||
|
rowCount: built.rowCount,
|
||||||
|
colored: built.colored,
|
||||||
|
degradeReason: built.degradeReason,
|
||||||
|
stats: built.stats,
|
||||||
|
topN: built.topN,
|
||||||
|
totalContestants: built.totalContestants,
|
||||||
|
noTeamCount: built.noTeamCount,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
// 中文文件名:content-disposition 会输出 filename*=UTF-8''… 形式,
|
||||||
|
// nginx 的 X-Accel 只丢弃自定义头、不丢弃 Content-Disposition(实测)
|
||||||
|
ctx.attachment(built.fileName);
|
||||||
|
ctx.body = built.buffer;
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('导出成绩排名表失败:', err);
|
||||||
|
if (err && err.bizCode === 'BAD_GROUP') {
|
||||||
|
ctx.body = ctx.helper.error(400, err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '导出成绩排名表失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 赛项信息配置(R176) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取赛项信息
|
||||||
|
* GET /api/admin/competition-item
|
||||||
|
*
|
||||||
|
* 成绩公示表标题的第 2、3 行要用(赛项名称 / 赛项编号)。
|
||||||
|
* 用户裁定:不做硬编码,改为两项配置由管理人员在后台填写。
|
||||||
|
*/
|
||||||
|
async competitionItemInfo() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
try {
|
||||||
|
const data = await service.admin.getCompetitionItemInfo();
|
||||||
|
ctx.body = ctx.helper.success(data);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('读取赛项信息失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '读取赛项信息失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存赛项信息
|
||||||
|
* PUT /api/admin/competition-item
|
||||||
|
* body: { itemName?, itemCode? }
|
||||||
|
*
|
||||||
|
* ⚠️ 破坏面:覆盖 competition_info 里两个键的值。只会影响公示表标题文案,
|
||||||
|
* 不影响成绩、账号、队伍;改错重填即可。
|
||||||
|
*/
|
||||||
|
async updateCompetitionItemInfo() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
try {
|
||||||
|
const body = ctx.request.body || {};
|
||||||
|
// 长度兜底:两个值都会写进 xlsx 标题单元格,过长会把标题撑爆
|
||||||
|
for (const [k, limit] of [['itemName', 120], ['itemCode', 60]]) {
|
||||||
|
const v = body[k];
|
||||||
|
if (v !== undefined && v !== null && String(v).length > limit) {
|
||||||
|
ctx.body = ctx.helper.error(400, `${k === 'itemName' ? '赛项名称' : '赛项编号'}不能超过 ${limit} 字`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const saved = await service.admin.setCompetitionItemInfo({
|
||||||
|
itemName: body.itemName,
|
||||||
|
itemCode: body.itemCode,
|
||||||
|
});
|
||||||
|
ctx.body = ctx.helper.success(saved, '赛项信息已保存');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('保存赛项信息失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '保存赛项信息失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,9 +477,12 @@ class AdminController extends Controller {
|
|||||||
if (!this._validateGroup(group, true)) return;
|
if (!this._validateGroup(group, true)) return;
|
||||||
|
|
||||||
// 中职/高职组使用简化版查询(contestants + exam_submissions)
|
// 中职/高职组使用简化版查询(contestants + exam_submissions)
|
||||||
|
// R195:默认只返回「参赛过的人」(有作答 / 有大题文件 / 已交卷);
|
||||||
|
// `?includeIdle=1` 才返回全部报名者(前端「显示未参赛」开关)。
|
||||||
const isExamGroup = group === 'zhongzhi' || group === 'gaozhi';
|
const isExamGroup = group === 'zhongzhi' || group === 'gaozhi';
|
||||||
|
const includeIdle = ctx.query.includeIdle === '1' || ctx.query.includeIdle === 'true';
|
||||||
const submissions = isExamGroup
|
const submissions = isExamGroup
|
||||||
? await service.admin.getExamSubmissions(group)
|
? await service.admin.getExamSubmissions(group, { includeIdle })
|
||||||
: await service.admin.getSubmissions(group);
|
: await service.admin.getSubmissions(group);
|
||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
group,
|
group,
|
||||||
@@ -338,7 +579,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,10 +595,31 @@ 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) {
|
||||||
|
// 🔴 R195 修既有缺陷:「该组一个已上传文件都没有」是**正常业务态**,不是服务故障。
|
||||||
|
// `service.admin.batchDownloadZip` 为此专门抛 `bizCode:'EMPTY'`,但这里原先没有分支
|
||||||
|
// 接住它 ⇒ 落到兜底 500「批量下载失败」,管理端完全看不出真实原因,
|
||||||
|
// 且每点一次下载就刷一条 ERROR 日志(噪音淹没真故障)。
|
||||||
|
// ⚠️ 不能用 HTTP 4xx:本项目约定 `helper.error()` 不改状态码,业务错 = 200 + code≠0。
|
||||||
|
if (err && err.bizCode === 'EMPTY') {
|
||||||
|
ctx.logger.warn(`[batchDownload] 组 ${ctx.params.group} 暂无已上传文件(业务 404)`);
|
||||||
|
ctx.body = ctx.helper.error(404, err.message || '该组暂无已上传的作品文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.logger.error('批量下载失败:', err);
|
ctx.logger.error('批量下载失败:', err);
|
||||||
|
|
||||||
// 如果 archiver 不可用,返回友好错误
|
// 如果 archiver 不可用,返回友好错误
|
||||||
@@ -370,8 +632,60 @@ class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 时间管理 ====================
|
// ==================== 单小组作品压缩包(R190) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载「单个小组」的作品压缩包
|
||||||
|
* GET /api/admin/team-zip/:group?teamUid=xx 或 ?aggregationUid=xx
|
||||||
|
*
|
||||||
|
* 【业务来源】「管理人员下载作品时将这个文件夹进行压缩,直接下载压缩包」
|
||||||
|
* 包内**平铺**该小组的文件(zip 本身即小组目录),文件名保留下传时的原名。
|
||||||
|
*
|
||||||
|
* @returns {Stream} zip 流
|
||||||
|
*/
|
||||||
|
async teamZip() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
// 只有本科/教师组有作品文件(中职/高职组走答题答案通道)
|
||||||
|
if (!['benke', 'jiaoshi'].includes(group)) {
|
||||||
|
ctx.body = ctx.helper.error(400, '该组别无作品文件,仅本科组/教师组支持');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipInfo = await service.admin.teamZip(group, {
|
||||||
|
teamUid: ctx.query.teamUid,
|
||||||
|
aggregationUid: ctx.query.aggregationUid,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendFile(ctx, this.app, {
|
||||||
|
absolutePath: zipInfo.zipFilePath,
|
||||||
|
fileName: zipInfo.downloadName,
|
||||||
|
contentType: 'application/zip',
|
||||||
|
useXAccel: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 下载完成后清理临时 zip(Node 流模式下 res.finish 在流发完后触发)
|
||||||
|
ctx.res.on('finish', () => {
|
||||||
|
ctx.helper.safeCleanup(zipInfo.zipFilePath, { label: 'teamZip-zipFile' });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.bizCode === 'EMPTY') {
|
||||||
|
ctx.body = ctx.helper.error(404, '该小组暂无已上传的作品文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err.bizCode === 'NOT_FOUND') {
|
||||||
|
ctx.body = ctx.helper.error(404, err.message || '未找到该小组');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.logger.error('小组作品打包失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '小组作品打包失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 时间管理 ====================
|
||||||
/**
|
/**
|
||||||
* 批量设置比赛时间
|
* 批量设置比赛时间
|
||||||
* PUT /api/admin/times
|
* PUT /api/admin/times
|
||||||
@@ -403,9 +717,19 @@ class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await service.admin.setTimes(times);
|
// B② 乐观锁:客户端可回传它读到的旧值(expectedTimes);与库里现值不一致
|
||||||
|
// 即判冲突,避免后保存者静默覆盖先保存者的结果。不传 ⇒ 退回旧行为。
|
||||||
|
const expectedTimes = (ctx.request.body && ctx.request.body.expectedTimes) || null;
|
||||||
|
await service.admin.setTimes(times, { expected: expectedTimes });
|
||||||
ctx.body = ctx.helper.success(null, '比赛时间设置成功');
|
ctx.body = ctx.helper.success(null, '比赛时间设置成功');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 🔴 并发冲突是业务性保护,不是 500:必须「200 + 业务码」。
|
||||||
|
// 按 500 返回会让前端拦截器提示「服务器错误」,掩盖真实原因。
|
||||||
|
if (err && err.name === 'TimesConflictError') {
|
||||||
|
ctx.logger.warn('[setTimes] 时间配置并发冲突: %j', err.conflicts);
|
||||||
|
ctx.body = ctx.helper.error(4090, '比赛时间已被他人修改,请刷新后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
ctx.logger.error('设置比赛时间失败:', err);
|
ctx.logger.error('设置比赛时间失败:', err);
|
||||||
ctx.body = ctx.helper.error(500, '设置比赛时间失败');
|
ctx.body = ctx.helper.error(500, '设置比赛时间失败');
|
||||||
}
|
}
|
||||||
@@ -428,6 +752,56 @@ class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点备份调度器状态
|
||||||
|
* GET /api/admin/node-backup/status
|
||||||
|
*
|
||||||
|
* P1「生产备份静默失败防护」的可见性出口:调度器此前只在 logger 里喊失败,
|
||||||
|
* 管理后台看不到 ⇒ 直到需要恢复时才发现备份不存在。本接口把
|
||||||
|
* **待触发计划 / 最近台账 / 备份产物 / 健康判定** 一次性暴露给管理页。
|
||||||
|
*
|
||||||
|
* ⛔ 只读接口:不排程、不触发备份、不写任何文件(读台账与目录而已)。
|
||||||
|
* ⛔ 返回体不含任何凭据:envFile 只给路径,绝不回显其内容(内含 DB 口令)。
|
||||||
|
*/
|
||||||
|
async getNodeBackupStatus() {
|
||||||
|
const { ctx } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scheduler = this.app.nodeBackupScheduler;
|
||||||
|
const isProduction = !!(this.app.config && this.app.config.isProduction);
|
||||||
|
|
||||||
|
if (!scheduler) {
|
||||||
|
// 🔴 最隐蔽的一种「静默失败」:调度器压根没构造成功(app.js 里被 try/catch 吞了)。
|
||||||
|
// 此时其余一切正常,只是备份永远不会执行 ⇒ 生产环境必须报红。
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
available: false,
|
||||||
|
enabled: false,
|
||||||
|
isProduction,
|
||||||
|
plans: [],
|
||||||
|
history: [],
|
||||||
|
artifacts: [],
|
||||||
|
health: {
|
||||||
|
level: isProduction ? 'error' : 'ok',
|
||||||
|
reasons: [{
|
||||||
|
level: isProduction ? 'error' : 'warn',
|
||||||
|
code: 'SCHEDULER_MISSING',
|
||||||
|
message: '节点备份调度器未初始化(app.js didReady 构造失败)⇒ 三节点备份不会执行,请查看应用启动日志',
|
||||||
|
}],
|
||||||
|
stats: { consecutiveFails: 0, missedCount: 0, artifactCount: 0, planCount: 0, historyCount: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = scheduler.status(50);
|
||||||
|
ctx.body = ctx.helper.success(Object.assign({ available: true }, status));
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('获取节点备份状态失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '获取节点备份状态失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 评分管理 ====================
|
// ==================== 评分管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -494,7 +868,10 @@ class AdminController extends Controller {
|
|||||||
const { group } = ctx.params;
|
const { group } = ctx.params;
|
||||||
if (!this._validateGroup(group, true)) return;
|
if (!this._validateGroup(group, true)) return;
|
||||||
|
|
||||||
const scores = await service.admin.getScores(group);
|
// R195:默认隐藏「从未参赛」的报名者(中职/高职按小组参赛,未上场组员全 0 分占行);
|
||||||
|
// `?includeIdle=1` 返回全量(前端「显示未参赛」开关)。
|
||||||
|
const includeIdle = ctx.query.includeIdle === '1' || ctx.query.includeIdle === 'true';
|
||||||
|
const scores = await service.admin.getScores(group, { includeIdle });
|
||||||
ctx.body = ctx.helper.success({ list: scores });
|
ctx.body = ctx.helper.success({ list: scores });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.logger.error('获取评分列表失败:', err);
|
ctx.logger.error('获取评分列表失败:', err);
|
||||||
@@ -623,6 +1000,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 +1019,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 +1031,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 +1166,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 +1526,93 @@ class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传新闻附件(R176)
|
||||||
|
* POST /api/admin/upload/news-file
|
||||||
|
*
|
||||||
|
* 用户裁定:成绩公示改为「新闻附件」形态,所以新闻要支持文件下载,
|
||||||
|
* 而不是把下载链接手写进正文(正文一旦人工编辑,链接极易被删掉)。
|
||||||
|
*
|
||||||
|
* 与 uploadNewsImage 复用同一目录(`app/public/news`)与同一访问路径前缀,
|
||||||
|
* 区别只在扩展名白名单与大小上限。
|
||||||
|
*
|
||||||
|
* @returns {{fileUrl: string, fileName: string, size: number, filename: string}}
|
||||||
|
*/
|
||||||
|
async uploadNewsFile() {
|
||||||
|
const { ctx } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
const ALLOWED_EXT = ['.xlsx', '.xls', '.pdf', '.docx', '.doc', '.zip'];
|
||||||
|
const MAX_BYTES = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await ctx.getFileStream();
|
||||||
|
if (!stream || !stream.filename) {
|
||||||
|
ctx.body = ctx.helper.error(400, '请选择要上传的附件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(stream.filename).toLowerCase();
|
||||||
|
if (ALLOWED_EXT.indexOf(ext) < 0) {
|
||||||
|
ctx.body = ctx.helper.error(400, `仅支持 ${ALLOWED_EXT.join(' / ')} 格式的附件`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BYTES) {
|
||||||
|
ctx.body = ctx.helper.error(400, '附件超过 20MB,请压缩后再上传');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
if (size === 0) {
|
||||||
|
ctx.body = ctx.helper.error(400, '附件内容为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const uploadDir = path.join(this.app.baseDir, 'app/public/news');
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存盘文件名与展示名分离:存盘用时间戳(避免覆盖与路径穿越),
|
||||||
|
// 展示名保留原始中文名,写进 news.attachment_name 供下载按钮显示。
|
||||||
|
const storedName = `news_file_${Date.now()}${ext}`;
|
||||||
|
fs.writeFileSync(path.join(uploadDir, storedName), Buffer.concat(chunks));
|
||||||
|
|
||||||
|
// 🔴 multipart 的 filename 常按 latin1 解出(中文变乱码)。
|
||||||
|
// 启发式:按 latin1 取回字节后若能完整 UTF-8 解码且含非 ASCII,就用解码结果。
|
||||||
|
let displayName = String(stream.filename);
|
||||||
|
const asUtf8 = Buffer.from(displayName, 'latin1').toString('utf8');
|
||||||
|
if (asUtf8.indexOf('\uFFFD') < 0 && /[^\x00-\x7F]/.test(asUtf8)) {
|
||||||
|
displayName = asUtf8;
|
||||||
|
}
|
||||||
|
// 去掉可能带进来的路径部分(Windows 上传会带 C:\fakepath\)
|
||||||
|
displayName = displayName.replace(/^.*[\\/]/, '').slice(0, 200);
|
||||||
|
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
fileUrl: `/public/news/${storedName}`,
|
||||||
|
fileName: displayName,
|
||||||
|
size,
|
||||||
|
filename: storedName,
|
||||||
|
}, '附件上传成功');
|
||||||
|
} catch (err) {
|
||||||
|
// 🔴 multipart 层(egg-multipart 的 whitelist)会先于本方法抛 400,
|
||||||
|
// 统一转成人话;否则前端看到的是一句「上传新闻附件失败」+ 500,
|
||||||
|
// 管理员无从知道是「格式不支持」。
|
||||||
|
if (err && err.status === 400) {
|
||||||
|
ctx.body = ctx.helper.error(400, `文件格式不被允许,仅支持 ${ALLOWED_EXT.join(' / ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.logger.error('上传新闻附件失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '上传新闻附件失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载考生答案 Word 文件
|
* 下载考生答案 Word 文件
|
||||||
* GET /api/admin/submissions/:group/:id/word
|
* GET /api/admin/submissions/:group/:id/word
|
||||||
@@ -1196,8 +1677,23 @@ class AdminController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = wordFileName || path.basename(filePath);
|
let fileName = wordFileName || path.basename(filePath);
|
||||||
filePathCache.setFull(wordCacheKey, wordFilePath, wordFileName);
|
if (isExamGroup) {
|
||||||
|
// 🔴 R195:中职/高职大题下载名 = `<小组编号>-<姓名>-<原文件名>`
|
||||||
|
// 小组编号口径走 resolveTeamLabels(teamNumber → 未编组_<uid>),
|
||||||
|
// 与「选手管理 / 提交记录」列表展示、作品落盘目录**同源**,⛔ 不取 contestants.team_id。
|
||||||
|
// 缓存里存的是**最终名**,故命中缓存时无需再查库。
|
||||||
|
const teamFolders = await service.admin.getTeamFolders(group, [id]);
|
||||||
|
const tf = teamFolders.get(String(id)) || {};
|
||||||
|
fileName = buildTeamDownloadName({
|
||||||
|
teamNumber: tf.teamNumber,
|
||||||
|
teamUid: tf.teamUid,
|
||||||
|
aggregationUid: id,
|
||||||
|
personName: tf.name,
|
||||||
|
originalName: fileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
filePathCache.setFull(wordCacheKey, wordFilePath, fileName);
|
||||||
// 🔴 X-Accel-Redirect 根治:生产经 nginx 内部重定向吐文件,开发回退流式。
|
// 🔴 X-Accel-Redirect 根治:生产经 nginx 内部重定向吐文件,开发回退流式。
|
||||||
// 文件名编码与路径穿越防护、文件 IO 并发护栏统一在 _sendFile 内处理(见其注释)。
|
// 文件名编码与路径穿越防护、文件 IO 并发护栏统一在 _sendFile 内处理(见其注释)。
|
||||||
await this._sendFile(
|
await this._sendFile(
|
||||||
@@ -1771,6 +2267,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 +2406,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 +2491,162 @@ class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 大题成绩 Excel 导入(R172) ====================
|
||||||
|
//
|
||||||
|
// 流程:下载模板 → 上传并**预览**(零写入)→ 确认后**导入**(写入走唯一评分路径)。
|
||||||
|
// 两段式是刻意的:成绩误写不可撤销,先让管理员看到每一行的判定结果再落库。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把上传的 Excel 读进内存 Buffer(⛔ 不落盘)。
|
||||||
|
*
|
||||||
|
* 为什么不落盘:
|
||||||
|
* ① 成绩表含考生成绩(敏感),既有题目导入落在 `app/public/tmp/excel`,
|
||||||
|
* 而 `app/public` 由 egg-static 对外暴露 ⇒ 文件名可猜即可下载;
|
||||||
|
* ② 不落盘就不存在「finally 忘了清理」留下的孤儿文件(R161 那类问题的根因)。
|
||||||
|
* 体积上限 2MB 足够(成绩表通常几十行)。
|
||||||
|
*
|
||||||
|
* @param {number} maxBytes
|
||||||
|
* @returns {Promise<Buffer|null>} 失败时已写好响应体,返回 null
|
||||||
|
*/
|
||||||
|
async _readExcelBuffer(maxBytes = 2 * 1024 * 1024) {
|
||||||
|
const { ctx } = this;
|
||||||
|
let stream;
|
||||||
|
try {
|
||||||
|
stream = await ctx.getFileStream();
|
||||||
|
} catch (e) {
|
||||||
|
ctx.body = ctx.helper.error(400, '未接收到文件(需 multipart/form-data,字段名 file)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(stream.filename || '').toLowerCase();
|
||||||
|
if (!['.xlsx', '.xls'].includes(ext)) {
|
||||||
|
// 必须把流消费掉,否则连接会悬挂
|
||||||
|
stream.resume();
|
||||||
|
ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > maxBytes) {
|
||||||
|
stream.resume();
|
||||||
|
ctx.body = ctx.helper.error(400, `文件过大(上限 ${Math.floor(maxBytes / 1024 / 1024)} MB)`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ctx.body = ctx.helper.error(400, '读取上传文件失败:' + (e && e.message ? e.message : '未知错误'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!size) {
|
||||||
|
ctx.body = ctx.helper.error(400, '上传文件为空');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载「综合大题成绩导入」模板
|
||||||
|
* GET /api/admin/big-answers/:group/import-template
|
||||||
|
*/
|
||||||
|
async downloadBigScoreTemplate() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
if (!service.bigQuestion.isEnabledGroup(group)) {
|
||||||
|
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await service.bigQuestion.buildScoreTemplate(group);
|
||||||
|
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
ctx.attachment(`综合大题成绩导入模板_${group}.xlsx`);
|
||||||
|
ctx.body = buffer;
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('下载大题成绩导入模板失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '生成模板失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览成绩导入(⛔ 零写入)
|
||||||
|
* POST /api/admin/big-answers/:group/import-preview
|
||||||
|
* multipart/form-data: file
|
||||||
|
*/
|
||||||
|
async previewBigScoreImport() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
const buffer = await this._readExcelBuffer();
|
||||||
|
if (!buffer) return;
|
||||||
|
|
||||||
|
const result = await service.bigQuestion.previewScoreImport(group, buffer);
|
||||||
|
if (!result.ok) {
|
||||||
|
ctx.body = ctx.helper.error(400, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = ctx.helper.success(
|
||||||
|
result,
|
||||||
|
`预览完成:可导入 ${result.importable} 行,将覆盖 ${result.overwritable} 行,被拒绝 ${result.blocked} 行`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('大题成绩导入预览失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '预览失败:' + (err && err.message ? err.message : '未知错误'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行成绩导入
|
||||||
|
* POST /api/admin/big-answers/:group/import-scores?overwrite=1
|
||||||
|
* multipart/form-data: file
|
||||||
|
*/
|
||||||
|
async importBigScores() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
const buffer = await this._readExcelBuffer();
|
||||||
|
if (!buffer) return;
|
||||||
|
|
||||||
|
const overwrite = ctx.query.overwrite === '1' || ctx.query.overwrite === 'true';
|
||||||
|
const result = await service.bigQuestion.importScores(group, buffer, { overwrite });
|
||||||
|
if (!result.ok) {
|
||||||
|
ctx.body = ctx.helper.error(400, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// preview 明细过大时不必每次回传全部行;这里保留前 200 条错误供前端展示
|
||||||
|
const errors = (result.errors || []).slice(0, 200);
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
imported: result.imported,
|
||||||
|
skipped: result.skipped,
|
||||||
|
overwritten: result.overwritten,
|
||||||
|
errors,
|
||||||
|
errorTotal: (result.errors || []).length,
|
||||||
|
untruncated: (result.errors || []).length <= 200,
|
||||||
|
}, `导入完成:成功 ${result.imported} 行(其中覆盖 ${result.overwritten} 行),跳过 ${result.skipped} 行`);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('大题成绩导入失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '导入失败:' + (err && err.message ? err.message : '未知错误'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新增大题
|
* 新增大题
|
||||||
* POST /api/admin/big-questions/:group
|
* POST /api/admin/big-questions/:group
|
||||||
|
|||||||
118
app/controller/adminUser.js
Normal file
118
app/controller/adminUser.js
Normal 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;
|
||||||
@@ -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,11 +374,37 @@ class AuthController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// P2-3 默认口令守卫:schema.sql 内置默认管理员(admin/admin123),
|
// 🔴 R178 禁用检查:必须在口令校验**之后**。
|
||||||
|
// 若放在校验之前,「账号已禁用」与「密码错误」的差别会让未授权者探测出
|
||||||
|
// 某个用户名真实存在且已被停用。校验通过后才告知状态,不额外泄露信息。
|
||||||
|
if (Number(admin.status) !== 1) {
|
||||||
|
ctx.logger.warn(`[安全] 已禁用的管理员 ${admin.username} 尝试登录,已拒绝`);
|
||||||
|
ctx.body = ctx.helper.error(4006, '该管理员账号已被禁用,请联系最高管理员');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2-3 默认口令守卫:内置默认管理员(见 database/schema.sql 与 database/seeders/002-seed-admin.js),
|
||||||
// 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管;
|
// 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管;
|
||||||
// 非生产环境放行但记录告警,并在响应中提示尽快改密。
|
// 非生产环境放行但记录告警,并在响应中提示尽快改密。
|
||||||
const DEFAULT_ADMIN_PASSWORDS = ['admin123', 'admin123456'];
|
//
|
||||||
const usingDefaultPassword = DEFAULT_ADMIN_PASSWORDS.includes(password);
|
// ⚠️ 【R111 更正 · 实测】原注释写「schema.sql 内置默认管理员(admin/admin123)」,两处失实:
|
||||||
|
// ① 归属错 —— `admin123` 是 **seeder 002** 的 bcrypt 值;`schema.sql:214` 的字面哈希实测 = SHA256(`admin`)。
|
||||||
|
// ② 列表漏项 —— 原列表 `['admin123','admin123456']` **不含 `admin`**,而 `schema.sql` 建库路径的初始
|
||||||
|
// 口令恰为 `admin` ⇒ 生产若未改密,用 `admin/admin` 登录**不会**触发 4005(`verifyPassword` 兼容
|
||||||
|
// SHA256 ⇒ 可直接登录成功)⇒ 恰是本守卫要防的场景却不设防。已补入 `'admin'`。
|
||||||
|
// 详见 `.workbuddy/memory/BACKLOG.md` 候选⑱/㉑。
|
||||||
|
// ⚠️ 结构性缺口**未消除**:本守卫是「口令字面」判据 ⇒ 任何未枚举的默认口令均绕过;
|
||||||
|
// 彻底治法应改为「哈希值判据」(DB 值 ∈ 已知默认哈希集)。见候选㉑。
|
||||||
|
// ✅ 【R112 已消除 · 候选㉒】上述「口令字面」单通道已升级为**双通道**,并把清单下沉为**单源**:
|
||||||
|
// ① 口令通道:`DEFAULT_ADMIN_PASSWORDS.includes(password)`(原逻辑原样保留);
|
||||||
|
// ② 哈希通道:`isDefaultAdminHash(admin.password)` —— **与用户输入无关**,直接判 DB 存储值
|
||||||
|
// 是否为已发布的默认哈希(无盐 SHA256 + 固定盐 bcrypt),并对候选口令做 bcrypt 遍历,
|
||||||
|
// 以覆盖「同口令、**不同盐**」的重新播种场景(见 `002-seed-admin.js` 的 ADMIN_INIT_PASSWORD 分支)。
|
||||||
|
// ⇒ 上面那条「未枚举即绕过」的绕过面已消除;仅剩「新增内置凭据未登记」这一**显式维护点**
|
||||||
|
// (清单已下沉至 `lib/passwordPolicy.js` 模块头 ⚠️,⛔ 勿在本控制器内再写第二份)。
|
||||||
|
const usingDefaultPassword =
|
||||||
|
passwordPolicy.isDefaultAdminHash(admin.password) ||
|
||||||
|
passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password);
|
||||||
const isProd = this.app.config.isProduction;
|
const isProd = this.app.config.isProduction;
|
||||||
if (usingDefaultPassword && isProd) {
|
if (usingDefaultPassword && isProd) {
|
||||||
ctx.logger.error(`[安全阻断] 管理员 ${admin.username} 在生产环境尝试使用默认密码登录,已拒绝`);
|
ctx.logger.error(`[安全阻断] 管理员 ${admin.username} 在生产环境尝试使用默认密码登录,已拒绝`);
|
||||||
@@ -393,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,
|
||||||
|
|||||||
@@ -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, '提交答案失败');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ class UploadController extends Controller {
|
|||||||
* 上传作品文件
|
* 上传作品文件
|
||||||
* POST /api/upload/:group
|
* POST /api/upload/:group
|
||||||
*
|
*
|
||||||
* 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 50MB
|
* 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 150MB
|
||||||
|
* (🔴 R225:原注释写「最大 50MB」早已失效,实际上限只由
|
||||||
|
* config.multipart.fileSize 决定,当时为 '100mb',现为 '150mb'。
|
||||||
|
* 本注释不再复述数字,改为指向配置项,避免再次漂移。)
|
||||||
*/
|
*/
|
||||||
async upload() {
|
async upload() {
|
||||||
const { ctx, service } = this;
|
const { ctx, service } = this;
|
||||||
@@ -107,6 +110,25 @@ class UploadController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔴 R190 组内上传互斥:本科/教师组「小组中只要有一人上传,其他人就不能上传」,
|
||||||
|
// 替换权只归组内最早提交者(归属人)。⚠️ 必须放在 ctx.getFileStream() **之前** ——
|
||||||
|
// 被锁成员根本不该把几十 MB 请求体传上来再被拒(白耗入带宽 + 通道配额)。
|
||||||
|
const gate = await service.upload.assertTeamUploadAllowed(aggregation, group);
|
||||||
|
if (!gate.ok) {
|
||||||
|
// 业务错误 = HTTP 200 + code≠0(helper.error 不改 HTTP 状态),
|
||||||
|
// 附加 data 供前端在按钮/提示里展示归属人,message 已含完整可读文案。
|
||||||
|
const body = ctx.helper.error(6006, gate.message);
|
||||||
|
body.data = {
|
||||||
|
teamLocked: true,
|
||||||
|
teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null,
|
||||||
|
teamNumber: gate.state.teamNumber,
|
||||||
|
folderName: gate.state.folderName,
|
||||||
|
};
|
||||||
|
ctx.body = body;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const teamFolder = gate.state.folderName;
|
||||||
|
|
||||||
// 获取上传的文件流
|
// 获取上传的文件流
|
||||||
const stream = await ctx.getFileStream();
|
const stream = await ctx.getFileStream();
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
@@ -132,15 +154,18 @@ class UploadController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存文件
|
// 获取作品名称与文件类型(流式模式字段在 stream.fields 中)。
|
||||||
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group);
|
// ⚠️ 必须先取再落盘:落盘要用 file_type 判定「同名文件是否同一次提交的新版本」。
|
||||||
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
|
|
||||||
|
|
||||||
// 获取作品名称(流式模式字段在 stream.fields 中)
|
|
||||||
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
|
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
|
||||||
// 文件类型:word / ppt(前端上传时透传)
|
|
||||||
const fileType = (stream.fields && stream.fields.file_type) || 'word';
|
const fileType = (stream.fields && stream.fields.file_type) || 'word';
|
||||||
|
|
||||||
|
// 保存文件:落点 = works/<group>/<小组编号>/<原始文件名>
|
||||||
|
const fileInfo = await service.upload.saveWorkFile(
|
||||||
|
stream, stream.filename, group, teamFolder,
|
||||||
|
{ fileType, aggregationUid: aggregation.aggregation_uid }
|
||||||
|
);
|
||||||
|
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
|
||||||
|
|
||||||
// 保存提交记录
|
// 保存提交记录
|
||||||
const record = await service.upload.saveSubmissionRecord(
|
const record = await service.upload.saveSubmissionRecord(
|
||||||
aggregation.aggregation_uid,
|
aggregation.aggregation_uid,
|
||||||
@@ -191,6 +216,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;
|
||||||
|
|
||||||
@@ -265,6 +292,8 @@ class UploadController extends Controller {
|
|||||||
const { ctx, service, app } = this;
|
const { ctx, service, app } = this;
|
||||||
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
|
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
|
||||||
let mergedRelPath = null;
|
let mergedRelPath = null;
|
||||||
|
// 合并临时文件(先写临时名再原子 rename)。失败时需清掉,否则留下 `.merge_*` 残渣。
|
||||||
|
let mergedTmpPath = null;
|
||||||
let recordPersisted = false;
|
let recordPersisted = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -291,6 +320,22 @@ class UploadController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔴 R190 组内上传互斥:与 upload() 同一个守卫(两条落盘路径都必须拦,
|
||||||
|
// 否则被锁成员改走分片合并即可绕过组内锁)。
|
||||||
|
const gate = await service.upload.assertTeamUploadAllowed(aggregation, group);
|
||||||
|
if (!gate.ok) {
|
||||||
|
const body = ctx.helper.error(6006, gate.message);
|
||||||
|
body.data = {
|
||||||
|
teamLocked: true,
|
||||||
|
teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null,
|
||||||
|
teamNumber: gate.state.teamNumber,
|
||||||
|
folderName: gate.state.folderName,
|
||||||
|
};
|
||||||
|
ctx.body = body;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const teamFolder = gate.state.folderName;
|
||||||
|
|
||||||
// 检查分片目录
|
// 检查分片目录
|
||||||
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
|
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
|
||||||
if (!fs.existsSync(tempDir)) {
|
if (!fs.existsSync(tempDir)) {
|
||||||
@@ -310,16 +355,20 @@ class UploadController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建目标文件路径
|
// 构建落盘目标:小组目录 + 原始文件名(唯一命名实现 = service.prepareWorkTarget,
|
||||||
const uploadDir = path.join(app.baseDir, 'app/public/works', group);
|
// 与 saveWorkFile 同源,⛔ 不得在此另写一份随机命名)。
|
||||||
if (!fs.existsSync(uploadDir)) {
|
const target = await service.upload.prepareWorkTarget({
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
group,
|
||||||
}
|
folderName: teamFolder,
|
||||||
|
filename: fileName,
|
||||||
const ext = path.extname(fileName);
|
fileType: file_type || 'word',
|
||||||
const timestamp = Date.now();
|
aggregationUid: aggregation.aggregation_uid,
|
||||||
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`;
|
});
|
||||||
const targetPath = path.join(uploadDir, safeFileName);
|
// 先合并到临时名,再原子 rename 到最终名 —— 避免管理员在合并过程中下载到半截文件
|
||||||
|
mergedTmpPath = path.join(
|
||||||
|
target.dir,
|
||||||
|
`.merge_${Date.now()}_${Math.random().toString(36).substring(2, 8)}${path.extname(target.fileName)}`
|
||||||
|
);
|
||||||
|
|
||||||
// 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额,
|
// 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额,
|
||||||
// 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。
|
// 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。
|
||||||
@@ -332,8 +381,8 @@ class UploadController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 合并分片
|
// 合并分片(落到临时名)
|
||||||
const mergeResult = await service.upload.mergeFileChunks(chunkFiles, targetPath);
|
await service.upload.mergeFileChunks(chunkFiles, mergedTmpPath);
|
||||||
|
|
||||||
// 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。
|
// 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。
|
||||||
// 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会
|
// 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会
|
||||||
@@ -341,17 +390,11 @@ class UploadController extends Controller {
|
|||||||
// 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。
|
// 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。
|
||||||
ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' });
|
ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' });
|
||||||
|
|
||||||
// 构建相对路径
|
// 原子落位 + 计算 size/md5(filePath 为相对 app/public 的正斜杠路径)
|
||||||
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
|
const fileInfo = await service.upload.commitWorkFile(mergedTmpPath, target);
|
||||||
mergedRelPath = relativePath; // 供 catch 回滚孤儿文件用
|
mergedRelPath = fileInfo.filePath; // 供 catch 回滚孤儿文件用
|
||||||
|
|
||||||
// 保存提交记录
|
// 保存提交记录
|
||||||
const fileInfo = {
|
|
||||||
filePath: relativePath,
|
|
||||||
fileName,
|
|
||||||
fileSize: mergeResult.fileSize,
|
|
||||||
md5: mergeResult.md5,
|
|
||||||
};
|
|
||||||
const record = await service.upload.saveSubmissionRecord(
|
const record = await service.upload.saveSubmissionRecord(
|
||||||
aggregation.aggregation_uid,
|
aggregation.aggregation_uid,
|
||||||
group,
|
group,
|
||||||
@@ -365,11 +408,11 @@ class UploadController extends Controller {
|
|||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
workName: record.work_name,
|
workName: record.work_name,
|
||||||
filePath: relativePath,
|
filePath: fileInfo.filePath,
|
||||||
fileUrl: ctx.helper.fileUrl(relativePath),
|
fileUrl: ctx.helper.fileUrl(fileInfo.filePath),
|
||||||
fileName,
|
fileName: fileInfo.fileName,
|
||||||
fileSize: mergeResult.fileSize,
|
fileSize: fileInfo.fileSize,
|
||||||
md5: mergeResult.md5,
|
md5: fileInfo.md5,
|
||||||
uploadTime: record.upload_time || new Date().toISOString(),
|
uploadTime: record.upload_time || new Date().toISOString(),
|
||||||
}, '分片合并成功');
|
}, '分片合并成功');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -380,6 +423,10 @@ class UploadController extends Controller {
|
|||||||
if (mergedRelPath && !recordPersisted) {
|
if (mergedRelPath && !recordPersisted) {
|
||||||
service.upload.removePublicFile(mergedRelPath, 'merge-rollback');
|
service.upload.removePublicFile(mergedRelPath, 'merge-rollback');
|
||||||
}
|
}
|
||||||
|
// 合并中途失败 ⇒ 临时名文件也必须清掉(best-effort,绝不掩盖真实错误)
|
||||||
|
if (mergedTmpPath && !mergedRelPath) {
|
||||||
|
ctx.helper.safeCleanup(mergedTmpPath, { label: 'mergeChunks-failed' });
|
||||||
|
}
|
||||||
ctx.logger.error('分片合并失败:', err);
|
ctx.logger.error('分片合并失败:', err);
|
||||||
ctx.body = ctx.helper.error(500, '分片合并失败');
|
ctx.body = ctx.helper.error(500, '分片合并失败');
|
||||||
}
|
}
|
||||||
@@ -389,6 +436,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;
|
||||||
|
|
||||||
@@ -444,16 +493,33 @@ class UploadController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查提交状态
|
// 检查提交状态(本人)+ 组内互斥状态(R190)
|
||||||
const submitted = await service.upload.hasSubmitted(aggregation.aggregation_uid, group);
|
const [submitted, teamState, deadlineCheck] = await Promise.all([
|
||||||
|
service.upload.hasSubmitted(aggregation.aggregation_uid, group),
|
||||||
|
service.upload.getTeamUploadState(aggregation, group),
|
||||||
|
service.upload.validateDeadline(group),
|
||||||
|
]);
|
||||||
|
|
||||||
// 检查截止时间
|
// 🔴 组锁状态必须暴露给前端,否则被锁成员会看到「未上传」却任何操作都失败,
|
||||||
const deadlineCheck = await service.upload.validateDeadline(group);
|
// 完全不知道原因(同一类「状态不可见」缺陷在考试组锁上已踩过)。
|
||||||
|
const canUpload = deadlineCheck.valid && !teamState.locked;
|
||||||
|
|
||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
submitted,
|
submitted, // 本人是否已上传
|
||||||
deadline: deadlineCheck.deadline,
|
deadline: deadlineCheck.deadline,
|
||||||
canSubmit: deadlineCheck.valid,
|
canSubmit: canUpload, // 兼容旧字段:语义 = 现在能不能上传
|
||||||
|
canUpload,
|
||||||
|
deadlineValid: deadlineCheck.valid,
|
||||||
|
// ---- 小组维度 ----
|
||||||
|
teamNumber: teamState.teamNumber, // 人类可读小组编号(teams.team_number || team_id)
|
||||||
|
folderName: teamState.folderName, // 后端落盘用的目录名(前端只展示,不参与命名)
|
||||||
|
memberCount: teamState.memberCount,
|
||||||
|
teamSubmitted: teamState.hasAnySubmission, // 组内是否已有人上传
|
||||||
|
teamOwnerName: teamState.owner ? teamState.owner.name : null,
|
||||||
|
isTeamOwner: teamState.isOwner,
|
||||||
|
teamLocked: teamState.locked, // true = 组内别人已上传,本人不能再传
|
||||||
|
teamLockMessage: teamState.lockMessage,
|
||||||
|
teamFiles: teamState.teamFiles, // 组内已上传文件清单(含上传者)
|
||||||
}, '获取提交状态成功');
|
}, '获取提交状态成功');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.logger.error('查询提交状态失败:', err);
|
ctx.logger.error('查询提交状态失败:', err);
|
||||||
|
|||||||
288
app/lib/adminPermissions.js
Normal file
288
app/lib/adminPermissions.js
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理端权限点与「路由 → 权限」映射(R178·C 层唯一真相源)
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* 为什么需要这层:后端有 63 条 /api/admin/* 路由,原本全部只挂 adminAuth(role='admin')。
|
||||||
|
* 在本模块之前,「是管理员」等于「有全部权限」—— 无法做到「只给阅卷、不给发布」。
|
||||||
|
*
|
||||||
|
* 三条设计纪律(改动前必读):
|
||||||
|
*
|
||||||
|
* 1. **默认拒绝**:`resolvePermission()` 匹配不到任何规则时返回 null,
|
||||||
|
* 中间件对 null **直接 403**,而不是放行。
|
||||||
|
* ⇒ 新增路由若忘了登记规则,表现是「功能不可用」而非「静默成为人人可用的后门」。
|
||||||
|
*
|
||||||
|
* 2. **单一真相源**:路由的权限归属只在本文件声明,⛔ 不得在 router.js 逐条写权限参数
|
||||||
|
* (那会让 63 条路由各带一份权限字符串,与本表必然漂移,且无人能核对全集)。
|
||||||
|
* 全集对齐由门禁 `scripts/verify_admin_rbac.py` 双向断言:
|
||||||
|
* 「router.js 的 /api/admin 路由全集」== 「ROUTE_RULES 覆盖集」,两个方向差集都必须为空。
|
||||||
|
*
|
||||||
|
* 3. **多权限其一(anyOf)**:部分接口被两个菜单共用(如成绩复核页复用成绩列表),
|
||||||
|
* 用 `perms: ['scores','review']` 表达「具备任一即可」。这不会降低安全性 ——
|
||||||
|
* 任何一条都需被显式授予,只是避免「勾了复核却被 403」的误伤。
|
||||||
|
*
|
||||||
|
* 高危权限点单独成项(score.publish / data.reset / admins.manage),
|
||||||
|
* 目的是把「能看成绩」与「能发布成绩」分开 —— 这两件事的责任人通常不同。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MODULE = 'module';
|
||||||
|
const HIGH_RISK = 'highRisk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部权限点。顺序即前端展示顺序。
|
||||||
|
* ⚠️ 新增权限点必须同步:① 本数组;② ROUTE_RULES 至少一条规则;③ 前端 `can()` 使用处。
|
||||||
|
*/
|
||||||
|
const PERMISSIONS = [
|
||||||
|
{ key: 'dashboard', label: '数据总览', group: MODULE, desc: '查看各组账号数 / 提交数 / 激活率' },
|
||||||
|
{ key: 'accounts', label: '选手管理', group: MODULE, desc: '查看选手、导入报名表、编辑选手信息、标记测试数据' },
|
||||||
|
{ key: 'questions', label: '试卷管理', group: MODULE, desc: '客观题与综合大题题库维护、Word 模板' },
|
||||||
|
{ key: 'materials', label: '备赛资料', group: MODULE, desc: '上传 / 删除备赛资料' },
|
||||||
|
{ key: 'submissions', label: '作品管理', group: MODULE, desc: '查看作品与答题记录、下载作品文件' },
|
||||||
|
{ key: 'times', label: '时间设置', group: MODULE, desc: '考试起止时间、抽题数量、节点备份状态' },
|
||||||
|
{ key: 'scores', label: '成绩管理', group: MODULE, desc: '阅卷打分、导出成绩、生成公示表(不含发布)' },
|
||||||
|
{ key: 'review', label: '成绩复核', group: MODULE, desc: '按选手查验分项得分' },
|
||||||
|
{ key: 'news', label: '新闻管理', group: MODULE, desc: '新闻增删改、置顶、附件上传、发布 / 撤回' },
|
||||||
|
{ key: 'carousel', label: '轮播管理', group: MODULE, desc: '首页轮播图增删改与排序' },
|
||||||
|
{ key: 'score.publish', label: '发布 / 撤回成绩', group: HIGH_RISK, desc: '控制考生端能否看到成绩,并自动生成公示草稿' },
|
||||||
|
{ key: 'data.reset', label: '重置测试数据 / 删除选手', group: HIGH_RISK, desc: '会真实删除数据的操作,仅授予负责人' },
|
||||||
|
{ key: 'admins.manage', label: '管理员账号管理', group: HIGH_RISK, desc: '新增 / 删除管理员、分配权限、重置口令、禁用' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PERMISSION_KEYS = PERMISSIONS.map(p => p.key);
|
||||||
|
const PERMISSION_KEY_SET = new Set(PERMISSION_KEYS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路径模式 → 正则。`:xxx` 段匹配任意单段(不含 `/`)。
|
||||||
|
* 其余字符按字面转义,避免 `/api/admin/big-questions/:group/:id` 里的 `-` `.` 被当元字符。
|
||||||
|
*/
|
||||||
|
function toRegExp(pattern) {
|
||||||
|
const src = pattern
|
||||||
|
.split('/')
|
||||||
|
.map((seg) => {
|
||||||
|
if (seg.startsWith(':')) return '[^/]+';
|
||||||
|
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
})
|
||||||
|
.join('/');
|
||||||
|
return new RegExp('^' + src + '$');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由规则表。**顺序敏感**:更具体的路径必须排在带 `:id` 的通配路径之前
|
||||||
|
* (如 `/news/order` 必须在 `/news/:id` 之前),否则会被后者抢先匹配。
|
||||||
|
*
|
||||||
|
* ⚠️ 每条规则的 perms 至少一个;`perms` 语义 = 「具备其中之一即可」。
|
||||||
|
*/
|
||||||
|
const ROUTE_TABLE = [
|
||||||
|
// ---------- 自助信息(登录即可,无需业务权限) ----------
|
||||||
|
// 前端用它得知「我有哪些权限」以渲染菜单,并感知权限是否被他人改动。
|
||||||
|
// ⚠️ perms 为空数组是本表唯一允许的「显式放行」写法,门禁会断言只有本行如此。
|
||||||
|
['GET', '/api/admin/me', []],
|
||||||
|
|
||||||
|
// ---------- 管理员账号管理(高危) ----------
|
||||||
|
['GET', '/api/admin/admin-users', ['admins.manage']],
|
||||||
|
['POST', '/api/admin/admin-users', ['admins.manage']],
|
||||||
|
['PUT', '/api/admin/admin-users/:id/permissions', ['admins.manage']],
|
||||||
|
['PUT', '/api/admin/admin-users/:id/password', ['admins.manage']],
|
||||||
|
['PUT', '/api/admin/admin-users/:id/status', ['admins.manage']],
|
||||||
|
['DELETE', '/api/admin/admin-users/:id', ['admins.manage']],
|
||||||
|
|
||||||
|
// ---------- 数据总览 ----------
|
||||||
|
['GET', '/api/admin/stats', ['dashboard']],
|
||||||
|
|
||||||
|
// ---------- 选手管理 ----------
|
||||||
|
['GET', '/api/admin/accounts', ['accounts']],
|
||||||
|
['GET', '/api/admin/teams', ['accounts']],
|
||||||
|
['POST', '/api/admin/import-excel', ['accounts']],
|
||||||
|
['POST', '/api/admin/contestants/backfill-teacher', ['accounts']],
|
||||||
|
['PUT', '/api/admin/contestants/:id', ['accounts']],
|
||||||
|
['POST', '/api/admin/toggle-test-flag', ['accounts']],
|
||||||
|
// 重置测试数据是真实删除,单列高危(不给「只做选手维护」的人)
|
||||||
|
['POST', '/api/admin/reset-test-data', ['data.reset']],
|
||||||
|
|
||||||
|
// ---------- 作品管理 ----------
|
||||||
|
// 成绩管理页的「考生答题记录」也读 submissions 接口 ⇒ 允许 scores 其一
|
||||||
|
['GET', '/api/admin/submissions/:group', ['submissions', 'scores']],
|
||||||
|
['GET', '/api/admin/submission-file/:group/:id', ['submissions']],
|
||||||
|
['GET', '/api/admin/submissions/:group/:id/word', ['submissions']],
|
||||||
|
['GET', '/api/admin/works/:group', ['submissions']],
|
||||||
|
['GET', '/api/admin/batch-download/:group', ['submissions']],
|
||||||
|
// R190 单小组作品压缩包:与批量下载同属「作品管理」能力,同权限即可
|
||||||
|
['GET', '/api/admin/team-zip/:group', ['submissions']],
|
||||||
|
|
||||||
|
// ---------- 成绩管理(阅卷/导出/公示表) ----------
|
||||||
|
['GET', '/api/admin/scores/:group', ['scores', 'review']],
|
||||||
|
['PUT', '/api/admin/scores/:group', ['scores']],
|
||||||
|
['GET', '/api/admin/export-csv/:group', ['scores']],
|
||||||
|
['GET', '/api/admin/export-excel/:group', ['scores']],
|
||||||
|
['GET', '/api/admin/export-publicity/:group', ['scores']],
|
||||||
|
// R220 成绩排名表:同属「成绩管理」导出能力,与 export-excel / export-publicity 同权限
|
||||||
|
['GET', '/api/admin/export-score-rank/:group', ['scores']],
|
||||||
|
['GET', '/api/admin/competition-item', ['scores']],
|
||||||
|
['PUT', '/api/admin/competition-item', ['scores']],
|
||||||
|
['GET', '/api/admin/big-answers/:group', ['scores', 'review']],
|
||||||
|
['GET', '/api/admin/big-answers/:group/export', ['scores']],
|
||||||
|
['GET', '/api/admin/big-answers/:group/import-template', ['scores']],
|
||||||
|
['POST', '/api/admin/big-answers/:group/import-preview', ['scores']],
|
||||||
|
['POST', '/api/admin/big-answers/:group/import-scores', ['scores']],
|
||||||
|
['PUT', '/api/admin/big-answers/:group/:uid/scores', ['scores']],
|
||||||
|
['GET', '/api/admin/publish-scores', ['scores', 'review']],
|
||||||
|
// 发布 / 撤回 = 高危:能看成绩 ≠ 能公布成绩
|
||||||
|
['PUT', '/api/admin/publish-scores', ['score.publish']],
|
||||||
|
|
||||||
|
// ---------- 时间设置 ----------
|
||||||
|
['GET', '/api/admin/times', ['times']],
|
||||||
|
['PUT', '/api/admin/times', ['times']],
|
||||||
|
['GET', '/api/admin/node-backup/status', ['times']],
|
||||||
|
|
||||||
|
// ---------- 试卷管理 ----------
|
||||||
|
['GET', '/api/admin/exam/:group/questions', ['questions']],
|
||||||
|
['PUT', '/api/admin/exam/:group/questions', ['questions']],
|
||||||
|
['POST', '/api/admin/exam/:group/questions/import-excel', ['questions']],
|
||||||
|
['GET', '/api/admin/exam/:group/questions/template', ['questions']],
|
||||||
|
['PUT', '/api/admin/exam/:group/word-template', ['questions']],
|
||||||
|
['GET', '/api/admin/exam/:group/word-template', ['questions']],
|
||||||
|
['DELETE', '/api/admin/exam/:group/word-template', ['questions']],
|
||||||
|
['GET', '/api/admin/big-questions/:group', ['questions']],
|
||||||
|
['POST', '/api/admin/big-questions/:group', ['questions']],
|
||||||
|
['GET', '/api/admin/big-questions/:group/export', ['questions']],
|
||||||
|
['GET', '/api/admin/big-questions/:group/template', ['questions']],
|
||||||
|
['POST', '/api/admin/big-questions/:group/import-excel', ['questions']],
|
||||||
|
['PUT', '/api/admin/big-questions/:group/:id', ['questions']],
|
||||||
|
['DELETE', '/api/admin/big-questions/:group/:id', ['questions']],
|
||||||
|
|
||||||
|
// ---------- 备赛资料 ----------
|
||||||
|
['GET', '/api/admin/materials/:group', ['materials']],
|
||||||
|
['POST', '/api/admin/materials/:group', ['materials']],
|
||||||
|
['DELETE', '/api/admin/materials/:group/:fileName', ['materials']],
|
||||||
|
|
||||||
|
// ---------- 新闻管理 ----------
|
||||||
|
// ⚠️ /news/order 必须排在 /news/:id 之前
|
||||||
|
['PUT', '/api/admin/news/order', ['news']],
|
||||||
|
['GET', '/api/admin/news', ['news']],
|
||||||
|
['POST', '/api/admin/news', ['news']],
|
||||||
|
['PUT', '/api/admin/news/:id', ['news']],
|
||||||
|
['DELETE', '/api/admin/news/:id', ['news']],
|
||||||
|
['POST', '/api/admin/upload/news-image', ['news']],
|
||||||
|
['POST', '/api/admin/upload/news-file', ['news']],
|
||||||
|
|
||||||
|
// ---------- 轮播管理 ----------
|
||||||
|
// ⚠️ /carousel/order 必须排在 /carousel/:id 之前
|
||||||
|
['PUT', '/api/admin/carousel/order', ['carousel']],
|
||||||
|
['GET', '/api/admin/carousel', ['carousel']],
|
||||||
|
['POST', '/api/admin/carousel', ['carousel']],
|
||||||
|
['PUT', '/api/admin/carousel/:id', ['carousel']],
|
||||||
|
['DELETE', '/api/admin/carousel/:id', ['carousel']],
|
||||||
|
['POST', '/api/admin/upload/image', ['carousel']],
|
||||||
|
|
||||||
|
// ---------- 成绩复核 ----------
|
||||||
|
['GET', '/api/admin/review/search', ['review']],
|
||||||
|
['GET', '/api/admin/review/score/:group/:id', ['review']],
|
||||||
|
].map(([method, pattern, perms]) => ({ method, pattern, perms, re: toRegExp(pattern) }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析请求需要哪些权限(任一即可)。
|
||||||
|
*
|
||||||
|
* @param {string} method - HTTP 方法(大小写不敏感)
|
||||||
|
* @param {string} path - 请求路径(不含 query)
|
||||||
|
* @returns {string[]|null} 权限点数组;null = 未登记(调用方必须拒绝)
|
||||||
|
*/
|
||||||
|
function resolvePermission(method, path) {
|
||||||
|
const m = String(method || '').toUpperCase();
|
||||||
|
const p = String(path || '').split('?')[0];
|
||||||
|
for (const rule of ROUTE_TABLE) {
|
||||||
|
if (rule.method !== m) continue;
|
||||||
|
if (rule.re.test(p)) return rule.perms;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析数据库里的 permissions 列(JSON 数组文本)。
|
||||||
|
* 容错策略:解析失败 / 非数组 ⇒ 返回空数组(= 无权限),⛔ 绝不回落到「全权」。
|
||||||
|
*
|
||||||
|
* @param {string|null|undefined} raw
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function parsePermissions(raw) {
|
||||||
|
if (raw === null || raw === undefined || raw === '') return [];
|
||||||
|
let arr = raw;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try {
|
||||||
|
arr = JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
return arr.filter(k => typeof k === 'string' && PERMISSION_KEY_SET.has(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规整待写入的权限点:只保留已登记的 key,去重,按 PERMISSIONS 顺序排列。
|
||||||
|
* ⛔ 必须过滤未知值 —— 否则前端传错一个字符串就会被存进库,
|
||||||
|
* 将来某个新权限点恰好同名时会「凭空获得权限」。
|
||||||
|
*
|
||||||
|
* @param {string[]} list
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function sanitizePermissions(list) {
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
const set = new Set(list.filter(k => PERMISSION_KEY_SET.has(k)));
|
||||||
|
return PERMISSION_KEYS.filter(k => set.has(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断某管理员是否满足给定权限(任一命中即可)。
|
||||||
|
*
|
||||||
|
* ⚠️ 三种返回语义必须严格区分:
|
||||||
|
* required === null ⇒ 未登记路由(由 resolvePermission 返回,调用方必须拒绝)
|
||||||
|
* required === [] ⇒ **显式声明**「登录即可,无需业务权限」(如 /api/admin/me)
|
||||||
|
* required === ['a','b'] ⇒ 具备任一即可
|
||||||
|
*
|
||||||
|
* @param {{is_super?:number|boolean, permissions?:string[]|string}} admin
|
||||||
|
* @param {string[]|null} required
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function canAccess(admin, required) {
|
||||||
|
if (!admin) return false;
|
||||||
|
// 🔴 顺序不可换(R178 运行期判据 P3-2 抓到):**未登记路由对任何人都是拒绝**,
|
||||||
|
// 包括最高管理员。若把 is_super 判在前面,`canAccess(super, null)` 会返回 true ——
|
||||||
|
// 中间件虽然另有 `required === null ⇒ 403` 的前置分支,但那样「默认拒绝」就只活在
|
||||||
|
// 一处调用约定里:任何新调用方(例如前端按钮显隐的服务端预判)漏判即成后门。
|
||||||
|
// 此处作为第二道兜底,保证 null 永远不构成授权。
|
||||||
|
if (!Array.isArray(required)) return false;
|
||||||
|
if (Number(admin.is_super) === 1) return true;
|
||||||
|
// 空数组 = 显式放行(仅限登记为「登录即可」的接口)
|
||||||
|
if (required.length === 0) return true;
|
||||||
|
const owned = Array.isArray(admin.permissions)
|
||||||
|
? admin.permissions
|
||||||
|
: parsePermissions(admin.permissions);
|
||||||
|
return required.some(k => owned.includes(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号是否可被管理(改权限 / 重置口令 / 禁用 / 删除)。
|
||||||
|
* 最高管理员一律不可变动 —— 这是用户明确裁定的硬约束,集中在单点判断,
|
||||||
|
* ⛔ 不得在各接口里各写一遍 `id === 1`。
|
||||||
|
*
|
||||||
|
* @param {{is_super?:number|boolean}} admin
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isManageable(admin) {
|
||||||
|
return !!admin && Number(admin.is_super) !== 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PERMISSIONS,
|
||||||
|
PERMISSION_KEYS,
|
||||||
|
PERMISSION_KEY_SET,
|
||||||
|
ROUTE_TABLE,
|
||||||
|
resolvePermission,
|
||||||
|
parsePermissions,
|
||||||
|
sanitizePermissions,
|
||||||
|
canAccess,
|
||||||
|
isManageable,
|
||||||
|
MODULE,
|
||||||
|
HIGH_RISK,
|
||||||
|
};
|
||||||
BIN
app/lib/assets/publicity-template.xlsx
Normal file
BIN
app/lib/assets/publicity-template.xlsx
Normal file
Binary file not shown.
78
app/lib/auditLog.js
Normal file
78
app/lib/auditLog.js
Normal 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
429
app/lib/bigScoreImport.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|||||||
844
app/lib/nodeBackupScheduler.js
Normal file
844
app/lib/nodeBackupScheduler.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -24,6 +24,10 @@
|
|||||||
* · `app/controller/auth.js` → `activate()` (短信激活,设置初始口令)
|
* · `app/controller/auth.js` → `activate()` (短信激活,设置初始口令)
|
||||||
* · `app/controller/auth.js` → `resetPassword()` (忘记密码,重设口令)
|
* · `app/controller/auth.js` → `resetPassword()` (忘记密码,重设口令)
|
||||||
*
|
*
|
||||||
|
* 另有一项**非强度**职责也收在本模块(保持「管理员默认凭据」口径单源,见文件末段):
|
||||||
|
* · `app/controller/auth.js` → `adminLogin()` 的「默认口令守卫」读取本模块的
|
||||||
|
* `DEFAULT_ADMIN_PASSWORDS` / `isDefaultAdminHash()` —— R112 · 候选㉒ 下沉。
|
||||||
|
*
|
||||||
* 不适用范围(重要,勿误改)
|
* 不适用范围(重要,勿误改)
|
||||||
* ------------------------------------------------------------
|
* ------------------------------------------------------------
|
||||||
* 以下链路**直写数据库、不经过本策略**,是**设计内**行为,不是绕过:
|
* 以下链路**直写数据库、不经过本策略**,是**设计内**行为,不是绕过:
|
||||||
@@ -220,6 +224,96 @@ function requireTestPassword(appConfig) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ── 内置默认管理员凭据(登录守卫的唯一真相源)──────────────────────
|
||||||
|
*
|
||||||
|
* 背景(BACKLOG 候选㉒ · R112)
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* `controller/auth.js` 的「默认口令守卫」原为**口令字面**判据(用户输入的明文
|
||||||
|
* 是否命中一张硬编码清单)。该判据有两个结构缺口:
|
||||||
|
* ① 清单**散落在 controller 内** —— 与 `database/schema.sql`、`backend/database/seeders/002-seed-admin.js`
|
||||||
|
* 各持一份口径,属「多真相源」,任一处改动都不会带动其余(R111 血证:守卫曾漏 `admin`);
|
||||||
|
* ② 只覆盖**已枚举**的默认口令 —— 任何未列出的内置默认口令(或清单被改动)即**静默绕过**。
|
||||||
|
*
|
||||||
|
* 治法:把「默认凭据」下沉到本模块(口令策略 SoT)成为**单一真相源**,并补一条
|
||||||
|
* **与用户输入无关**的判据 —— 直接看 DB 里存的哈希是否即已发布的默认哈希。
|
||||||
|
*
|
||||||
|
* ⚠️ bcrypt 的盐是**随机**的:同一口令每次哈希结果都不同 ⇒ ⛔ 不能只比对哈希字符串。
|
||||||
|
* 故本模块提供**两条互补通道**,由调用方 OR 合并(见 `controller/auth.js adminLogin`):
|
||||||
|
* · 哈希通道 `isDefaultAdminHash(dbHash)` —— 覆盖「DB 里就是内置的那串哈希」
|
||||||
|
* (R113 前 `schema.sql` 的无盐 SHA256;R113 起 `schema.sql` 与 `seeder 002`
|
||||||
|
* 统一为**同一固定盐 bcrypt**),**与输入无关**;
|
||||||
|
* · 口令通道 `DEFAULT_ADMIN_PASSWORDS.includes(明文)` —— 覆盖「同口令、不同盐」
|
||||||
|
* 的**重新播种**场景(登录成功的明文必等于该默认口令,见 `002-seed-admin.js` 的
|
||||||
|
* `ADMIN_INIT_PASSWORD` 分支会以随机盐重算哈希)。
|
||||||
|
* 二者合并即覆盖「**已知**默认口令的任意存储形态」。
|
||||||
|
* ⚠️ 未列入清单的默认口令仍是缺口 ⇒ 新增内置账号时**必须同步登记到下方清单**
|
||||||
|
* (这是**刻意的显式维护点**,避免静默逃逸)。
|
||||||
|
* ✅ R114 已收口(本条曾登记的「尚无仓内机检」缺口):断言已**下沉进仓**为常驻门禁 ——
|
||||||
|
* `scripts/unit_password_policy.cjs` 第 [8] 段 + `scripts/unit_password_policy_mutation.cjs`
|
||||||
|
* (+4 组变异体),接线为 `scripts/ci-gates.sh` 第 **25 步**(两条 `run_gate` 均阻断)⇒
|
||||||
|
* 本清单被**清空 / 字面改写 / 判定整体禁用**时,电池即**自动报红**(判别力由变异测试自证)。
|
||||||
|
* ⚠️ 机检覆盖的是「本清单**自身的形状与判定行为**」(非空 · 双通道命中 · 非默认不误伤);
|
||||||
|
* 「本清单 ↔ `database/schema.sql` ↔ seeder 002」三处的**跨文件同值**仍靠**人工核对**。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 内置默认管理员口令(**唯一真相源**;登录守卫与文档口径均以此为准) */
|
||||||
|
const DEFAULT_ADMIN_PASSWORDS = ['admin', 'admin123', 'admin123456'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已发布默认管理员凭据的**字面哈希**(固定值,可精确比对)。
|
||||||
|
* · `8c69…4918`:SHA256(无盐) —— **R113 前** `database/schema.sql` 建库 INSERT 的产物;
|
||||||
|
* 自 **R113(候选㉔)** 起该文件已统一为下方 bcrypt ⇒ 本项作为**历史值保留**,
|
||||||
|
* 用于拦截仍存该哈希的旧库 / 旧备份(⛔ 不删:保留即安全 —— 非默认口令不会命中)。
|
||||||
|
* · `$2b$10$Nc6…co9.`:bcrypt(cost=10, 固定盐) —— 来源**两处已收敛为同值**:
|
||||||
|
* `database/schema.sql` 的建库 INSERT + `backend/database/seeders/002-seed-admin.js`。
|
||||||
|
*/
|
||||||
|
const KNOWN_DEFAULT_ADMIN_HASHES = [
|
||||||
|
'8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918',
|
||||||
|
'$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 字面哈希集合(小写归一化;bcrypt 前缀 `$2B$` 亦见于部分工具) */
|
||||||
|
const KNOWN_DEFAULT_ADMIN_HASH_SET =
|
||||||
|
new Set(KNOWN_DEFAULT_ADMIN_HASHES.map(h => h.toLowerCase()));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判定「DB 中存储的凭据哈希」是否为**已发布的默认值**(与用户输入无关)。
|
||||||
|
*
|
||||||
|
* 两级判定:
|
||||||
|
* ① 字面命中 `KNOWN_DEFAULT_ADMIN_HASHES`(覆盖无盐 SHA256 与固定盐 bcrypt 两种存量形态);
|
||||||
|
* ② 逐个候选默认口令做 bcrypt 校验(覆盖「同口令、**不同盐**」的重新播种 bcrypt 值 ——
|
||||||
|
* 这正是「不能只比哈希字符串」的原因)。
|
||||||
|
*
|
||||||
|
* ⚠️ 降级语义:`bcrypt` 采用**延迟加载**。本模块会被脚本 / 离线上下文(如变异测试写到
|
||||||
|
* 同目录的临时副本)加载,若该上下文取不到 `bcrypt`,则退化为「仅字面比对」(② 不可用),
|
||||||
|
* 且**不抛异常**(守卫降级属可接受,⛔ 不得因此阻断进程启动)。
|
||||||
|
*
|
||||||
|
* @param {string} dbHash - `admins.password` 的存储值
|
||||||
|
* @returns {boolean} 命中即 true
|
||||||
|
*/
|
||||||
|
function isDefaultAdminHash(dbHash) {
|
||||||
|
if (!dbHash) return false;
|
||||||
|
const h = String(dbHash).trim();
|
||||||
|
if (!h) return false;
|
||||||
|
if (KNOWN_DEFAULT_ADMIN_HASH_SET.has(h.toLowerCase())) return true;
|
||||||
|
|
||||||
|
let bcrypt = null;
|
||||||
|
try {
|
||||||
|
bcrypt = require('bcrypt');
|
||||||
|
} catch (e) {
|
||||||
|
return false; // 无 bcrypt ⇒ 退化为「仅字面比对」
|
||||||
|
}
|
||||||
|
for (const pw of DEFAULT_ADMIN_PASSWORDS) {
|
||||||
|
try {
|
||||||
|
if (bcrypt.compareSync(pw, h)) return true;
|
||||||
|
} catch (e) {
|
||||||
|
// 非法 bcrypt 形态(如 SHA256 十六进制串)⇒ 跳过,继续下一候选
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
checkPassword,
|
checkPassword,
|
||||||
requireTestPassword,
|
requireTestPassword,
|
||||||
@@ -228,4 +322,8 @@ module.exports = {
|
|||||||
STRENGTH_HINT,
|
STRENGTH_HINT,
|
||||||
// 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword()
|
// 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword()
|
||||||
BLACKLIST,
|
BLACKLIST,
|
||||||
|
// 默认管理员凭据判据(登录守卫用;见上文「内置默认管理员凭据」段)
|
||||||
|
DEFAULT_ADMIN_PASSWORDS,
|
||||||
|
KNOWN_DEFAULT_ADMIN_HASHES,
|
||||||
|
isDefaultAdminHash,
|
||||||
};
|
};
|
||||||
|
|||||||
559
app/lib/publicitySheet.js
Normal file
559
app/lib/publicitySheet.js
Normal 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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||||
|
function esc(s) {
|
||||||
|
return String(s === null || s === undefined ? '' : s).replace(/[&<>"']/g, c => XML_ESCAPE[c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_RE = /<row\b[^>]*\/>|<row\b[^>]*>[\s\S]*?<\/row>/g;
|
||||||
|
const CELL_RE = /<c\b[^>]*\/>|<c\b[^>]*>[\s\S]*?<\/c>/g;
|
||||||
|
|
||||||
|
function rowNumber(rowXml) {
|
||||||
|
const m = /<row\b[^>]*\br="(\d+)"/.exec(rowXml);
|
||||||
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从一行 XML 里取 { 列字母: s 值 } */
|
||||||
|
function rowStyles(rowXml) {
|
||||||
|
const out = {};
|
||||||
|
const cells = rowXml.match(CELL_RE) || [];
|
||||||
|
for (const c of cells) {
|
||||||
|
const rm = /\br="([A-Z]+)\d+"/.exec(c);
|
||||||
|
if (!rm) continue;
|
||||||
|
const sm = /\bs="(\d+)"/.exec(c);
|
||||||
|
if (sm) out[rm[1]] = sm[1];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成一个数据单元格(数值 / 文本 / 空) */
|
||||||
|
function cellXml(col, row, style, value) {
|
||||||
|
const ref = col + row;
|
||||||
|
const sAttr = style === undefined ? '' : ` s="${style}"`;
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return `<c r="${ref}"${sAttr}/>`;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && isFinite(value)) {
|
||||||
|
return `<c r="${ref}"${sAttr}><v>${value}</v></c>`;
|
||||||
|
}
|
||||||
|
// 用 inlineStr 而不是 sharedStrings:新引入的字符串不用再去维护共享表下标
|
||||||
|
return `<c r="${ref}"${sAttr} t="inlineStr"><is><t xml:space="preserve">${esc(value)}</t></is></c>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选「最常见的数据行样式」作为新行模板,避免拿模板里被手工改过的那一行当标准 */
|
||||||
|
function pickDataRowStyles(dataRows) {
|
||||||
|
const counted = new Map();
|
||||||
|
for (const r of dataRows) {
|
||||||
|
const st = rowStyles(r.xml);
|
||||||
|
const sig = DATA_COLS.map(c => st[c] === undefined ? '-' : st[c]).join(',');
|
||||||
|
const cur = counted.get(sig);
|
||||||
|
if (cur) cur.n++;
|
||||||
|
else counted.set(sig, { n: 1, styles: st });
|
||||||
|
}
|
||||||
|
let best = null;
|
||||||
|
for (const [, v] of counted) {
|
||||||
|
if (!best || v.n > best.n) best = v;
|
||||||
|
}
|
||||||
|
return best ? best.styles : { A: '6', B: '7', C: '7', D: '8', E: '6', F: '6', G: '6' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成新的 sheetData。
|
||||||
|
*
|
||||||
|
* @param {string} sheetXml 原 sheet1.xml
|
||||||
|
* @param {Array<Object>} rows buildPublicityRows 的产物
|
||||||
|
* @returns {{ xml: string, dataRows: number, tailRow: number, styles: Object }}
|
||||||
|
*/
|
||||||
|
function patchSheetData(sheetXml, rows) {
|
||||||
|
const sheetDataMatch = /<sheetData\b[^>]*>([\s\S]*?)<\/sheetData>/.exec(sheetXml);
|
||||||
|
if (!sheetDataMatch) throw new Error('模板缺少 <sheetData>');
|
||||||
|
|
||||||
|
const allRows = sheetDataMatch[1].match(ROW_RE) || [];
|
||||||
|
const head = [];
|
||||||
|
const data = [];
|
||||||
|
const tail = [];
|
||||||
|
for (const r of allRows) {
|
||||||
|
const n = rowNumber(r);
|
||||||
|
if (n <= 2) head.push(r);
|
||||||
|
else if (n >= SIGN_ROWS[0]) tail.push(r);
|
||||||
|
else data.push({ n, xml: r });
|
||||||
|
}
|
||||||
|
if (head.length !== 2) throw new Error(`模板表头行应为 2 行,实际 ${head.length}`);
|
||||||
|
if (data.length === 0) throw new Error('模板没有数据行可作样式参照');
|
||||||
|
// 🔴 必须同时守住两条签字行:只保留一条等于把「裁判长签字」或「仲裁长签字」丢了
|
||||||
|
if (tail.length !== SIGN_ROWS.length) {
|
||||||
|
throw new Error(`模板签字行应为 ${SIGN_ROWS.length} 条(row ${SIGN_ROWS.join(' / ')}),实际 ${tail.length} 条`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = pickDataRowStyles(data);
|
||||||
|
// 备注块 H3:J8 的空单元格样式:直接取模板数据行里 H/I/J 的 s(row 3 有备注文本,取 row 4 起)
|
||||||
|
const noteStyleFrom = {};
|
||||||
|
for (const d of data) {
|
||||||
|
if (d.n <= 8) {
|
||||||
|
const st = rowStyles(d.xml);
|
||||||
|
for (const c of NOTE_COLS) {
|
||||||
|
if (st[c] !== undefined && noteStyleFrom[c] === undefined) noteStyleFrom[c] = st[c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Math.max(rows.length, MIN_DATA_ROWS_FOR_NOTE);
|
||||||
|
const out = [];
|
||||||
|
for (const h of head) out.push(h);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const r = DATA_FIRST_ROW + i;
|
||||||
|
const row = rows[i] || null;
|
||||||
|
const cells = [
|
||||||
|
cellXml('A', r, styles.A, row ? row.seq : null),
|
||||||
|
cellXml('B', r, styles.B, row ? row.org : null),
|
||||||
|
cellXml('C', r, styles.C, row ? row.players : null),
|
||||||
|
cellXml('D', r, styles.D, row ? row.teachers : null),
|
||||||
|
cellXml('E', r, styles.E, row ? row.rank : null),
|
||||||
|
cellXml('F', r, styles.F, row ? row.score : null),
|
||||||
|
// 获奖等级:用户裁定「空置人工填入」⇒ 永远不写值
|
||||||
|
cellXml('G', r, styles.G, null),
|
||||||
|
];
|
||||||
|
// 备注合并区 H3:J8 需要这些单元格存在才能撑住边框
|
||||||
|
if (r <= 8) {
|
||||||
|
for (const c of NOTE_COLS) cells.push(cellXml(c, r, noteStyleFrom[c], null));
|
||||||
|
}
|
||||||
|
out.push(`<row r="${r}" customHeight="1" spans="1:7">${cells.join('')}</row>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = (count + (DATA_FIRST_ROW - 1)) - DATA_LAST_ROW; // 新数据末行 - 旧数据末行
|
||||||
|
const shiftRow = (xml, add) => {
|
||||||
|
if (add === 0) return xml;
|
||||||
|
return xml.replace(/(<row\b[^>]*\br=")(\d+)(")/, (m, p1, p2, p3) => p1 + (parseInt(p2, 10) + add) + p3)
|
||||||
|
.replace(/(<c\b[^>]*\br="[A-Z]+)(\d+)(")/g, (m, p1, p2, p3) => p1 + (parseInt(p2, 10) + add) + p3);
|
||||||
|
};
|
||||||
|
for (const t of tail) out.push(shiftRow(t, delta));
|
||||||
|
|
||||||
|
const newSheetData = `<sheetData>${out.join('')}</sheetData>`;
|
||||||
|
let xml = sheetXml.replace(sheetDataMatch[0], newSheetData);
|
||||||
|
|
||||||
|
// dimension 同步(否则 Excel 认为表格只到旧末行,滚动区/打印区会截断)
|
||||||
|
const tailRow = TEMPLATE_TAIL_ROW + delta;
|
||||||
|
xml = xml.replace(/<dimension\b[^>]*\bref="[^"]*"/, `<dimension ref="A1:J${tailRow}"`);
|
||||||
|
|
||||||
|
return { xml, dataRows: count, tailRow, styles };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 替换标题里的赛项名与赛项编号。
|
||||||
|
*
|
||||||
|
* 标题是 A1 一个单元格里的三行文本(存在 sharedStrings 第 0 项),
|
||||||
|
* 所以改 sharedStrings 比改 sheet1 更准(A1 是 t="s" 引用,改 sheet 只能整体替换)。
|
||||||
|
*
|
||||||
|
* @param {string} xml sharedStrings.xml 原文
|
||||||
|
* @param {{itemName?: string, itemCode?: string}} meta
|
||||||
|
* @returns {{ xml: string, replacedItem: boolean, replacedCode: boolean }}
|
||||||
|
*/
|
||||||
|
function patchSharedStringsTitle(xml, meta) {
|
||||||
|
const itemName = meta && meta.itemName ? String(meta.itemName).trim() : '';
|
||||||
|
const itemCode = meta && meta.itemCode ? String(meta.itemCode).trim() : '';
|
||||||
|
|
||||||
|
let replacedItem = false;
|
||||||
|
let replacedCode = false;
|
||||||
|
|
||||||
|
// 定位第 1 个 <si>(标题)
|
||||||
|
const siMatch = /<si>([\s\S]*?)<\/si>/.exec(xml);
|
||||||
|
if (!siMatch) throw new Error('sharedStrings 里没有 <si>');
|
||||||
|
|
||||||
|
let inner = siMatch[1];
|
||||||
|
|
||||||
|
if (itemName && inner.indexOf(TITLE_PLACEHOLDER_ITEM) >= 0) {
|
||||||
|
// 赛项名替换掉「***」,保留「(选拔赛/国内决赛)比赛结果公示表」的公文后缀
|
||||||
|
inner = inner.replace(TITLE_PLACEHOLDER_ITEM, `${esc(itemName)}(选拔赛/国内决赛)比赛结果公示表`);
|
||||||
|
replacedItem = true;
|
||||||
|
}
|
||||||
|
if (itemCode && inner.indexOf(TITLE_PLACEHOLDER_CODE) >= 0) {
|
||||||
|
// 编号为空时不追加,避免留下一个孤零零的「赛项编号:」
|
||||||
|
inner = inner.replace(TITLE_PLACEHOLDER_CODE, `${TITLE_PLACEHOLDER_CODE}${esc(itemCode)}`);
|
||||||
|
replacedCode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replacedItem && !replacedCode) return { xml, replacedItem, replacedCode };
|
||||||
|
|
||||||
|
const newSi = `<si>${inner}</si>`;
|
||||||
|
return { xml: xml.replace(siMatch[0], newSi), replacedItem, replacedCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整流程:模板字节 + 数据行 + 标题信息 → 新 xlsx 字节。
|
||||||
|
* @param {Buffer} templateBuf
|
||||||
|
* @param {Array<Object>} rows
|
||||||
|
* @param {{itemName?: string, itemCode?: string}} [meta]
|
||||||
|
*/
|
||||||
|
function buildXlsx(templateBuf, rows, meta) {
|
||||||
|
const entries = readZip(templateBuf);
|
||||||
|
|
||||||
|
const sheetEntry = entries.find(e => e.name === 'xl/worksheets/sheet1.xml');
|
||||||
|
if (!sheetEntry) throw new Error('模板缺少 xl/worksheets/sheet1.xml');
|
||||||
|
const sheetXml = entryText(sheetEntry);
|
||||||
|
const patched = patchSheetData(sheetXml, rows);
|
||||||
|
|
||||||
|
let next = replaceEntryText(entries, 'xl/worksheets/sheet1.xml', patched.xml);
|
||||||
|
|
||||||
|
const ssEntry = next.find(e => e.name === 'xl/sharedStrings.xml');
|
||||||
|
let titleResult = { replacedItem: false, replacedCode: false };
|
||||||
|
if (ssEntry) {
|
||||||
|
const ssXml = entryText(ssEntry);
|
||||||
|
const r = patchSharedStringsTitle(ssXml, meta || {});
|
||||||
|
titleResult = { replacedItem: r.replacedItem, replacedCode: r.replacedCode };
|
||||||
|
if (r.replacedItem || r.replacedCode) {
|
||||||
|
next = replaceEntryText(next, 'xl/sharedStrings.xml', r.xml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: writeZip(next),
|
||||||
|
dataRows: patched.dataRows,
|
||||||
|
tailRow: patched.tailRow,
|
||||||
|
title: titleResult,
|
||||||
|
styles: patched.styles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据构造(纯函数) ====================
|
||||||
|
|
||||||
|
/** 分数归一:null/''/NaN → null;字符串数字 → number */
|
||||||
|
function toScore(v) {
|
||||||
|
if (v === null || v === undefined || v === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return isFinite(n) ? Math.round(n * 100) / 100 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把「小组」数组排成公示表的行。
|
||||||
|
*
|
||||||
|
* 一行 = 一个小组(用户裁定:一组一人参赛、成绩全组共享 ⇒ 同一分数不该占多行)。
|
||||||
|
* 排序:有分的按分数**由高到低**,未评分的排最后;同分按团队编号升序保证稳定。
|
||||||
|
* 获奖等级不在此处产生(永远留空)。
|
||||||
|
*
|
||||||
|
* @param {Array<{teamUid?:number|string, teamId?:string, org?:string,
|
||||||
|
* players?:string[]|string, teachers?:string[]|string, score?:number|string}>} teams
|
||||||
|
* @returns {Array<{seq:number, org:string, players:string, teachers:string, rank:(number|null), score:(number|null), teamUid:any, teamId:string}>}
|
||||||
|
*/
|
||||||
|
function buildPublicityRows(teams) {
|
||||||
|
const list = Array.isArray(teams) ? teams : [];
|
||||||
|
const norm = list.map(t => {
|
||||||
|
const players = Array.isArray(t.players) ? t.players : (t.players ? [t.players] : []);
|
||||||
|
const teachers = Array.isArray(t.teachers) ? t.teachers : (t.teachers ? [t.teachers] : []);
|
||||||
|
return {
|
||||||
|
teamUid: t.teamUid === undefined ? null : t.teamUid,
|
||||||
|
teamId: t.teamId === undefined || t.teamId === null ? '' : String(t.teamId),
|
||||||
|
org: t.org === undefined || t.org === null ? '' : String(t.org),
|
||||||
|
players: uniqKeepOrder(players).join('、'),
|
||||||
|
teachers: uniqKeepOrder(teachers).join('、'),
|
||||||
|
score: toScore(t.score),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
norm.sort((a, b) => {
|
||||||
|
const sa = a.score === null ? -Infinity : a.score;
|
||||||
|
const sb = b.score === null ? -Infinity : b.score;
|
||||||
|
if (sb !== sa) return sb - sa;
|
||||||
|
// 未评分的两个也按编号定序,避免顺序随查询波动
|
||||||
|
if (a.teamId < b.teamId) return -1;
|
||||||
|
if (a.teamId > b.teamId) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
let rank = 0;
|
||||||
|
return norm.map((t, i) => {
|
||||||
|
// 未评分不参与排名(否则会出现「0 分并列第 N」的假排名)
|
||||||
|
if (t.score !== null) rank++;
|
||||||
|
return {
|
||||||
|
seq: i + 1,
|
||||||
|
org: t.org,
|
||||||
|
players: t.players,
|
||||||
|
teachers: t.teachers,
|
||||||
|
rank: t.score === null ? null : rank,
|
||||||
|
score: t.score,
|
||||||
|
teamUid: t.teamUid,
|
||||||
|
teamId: t.teamId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去重保序(同组同名只留一份) */
|
||||||
|
function uniqKeepOrder(arr) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const x of arr) {
|
||||||
|
const s = String(x === null || x === undefined ? '' : x).trim();
|
||||||
|
if (!s || seen.has(s)) continue;
|
||||||
|
seen.add(s);
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TEMPLATE_HEADERS,
|
||||||
|
DATA_FIRST_ROW,
|
||||||
|
DATA_LAST_ROW,
|
||||||
|
SIGN_ROWS,
|
||||||
|
RESERVED_DATA_ROWS,
|
||||||
|
TEMPLATE_TAIL_ROW,
|
||||||
|
TAIL_OFFSET,
|
||||||
|
MIN_DATA_ROWS_FOR_NOTE,
|
||||||
|
DATA_COLS,
|
||||||
|
TITLE_PLACEHOLDER_ITEM,
|
||||||
|
TITLE_PLACEHOLDER_CODE,
|
||||||
|
crc32,
|
||||||
|
findEOCD,
|
||||||
|
readZip,
|
||||||
|
writeZip,
|
||||||
|
entryText,
|
||||||
|
replaceEntryText,
|
||||||
|
rowNumber,
|
||||||
|
rowStyles,
|
||||||
|
cellXml,
|
||||||
|
pickDataRowStyles,
|
||||||
|
patchSheetData,
|
||||||
|
patchSharedStringsTitle,
|
||||||
|
buildXlsx,
|
||||||
|
toScore,
|
||||||
|
buildPublicityRows,
|
||||||
|
uniqKeepOrder,
|
||||||
|
};
|
||||||
685
app/lib/scoreRankSheet.js
Normal file
685
app/lib/scoreRankSheet.js
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成绩排名表导出引擎(R220)
|
||||||
|
*
|
||||||
|
* 目标:中职组 / 高职组考试结束后,按小组客观题分降序全量导出排名表,
|
||||||
|
* 整行按分数区间上底色,表尾给出三区间的小组数统计。
|
||||||
|
*
|
||||||
|
* 🔴 为什么要自己在 zip 层动 styles.xml:
|
||||||
|
* `@e965/xlsx`(SheetJS CE)写 xlsx 时**完全丢弃样式**(连 `s=` 属性都不写),
|
||||||
|
* 实测 2026-10-19:模板 214 个样式引用写回后变 0、styles.xml 从 22773 → 1546 字节。
|
||||||
|
* ⇒ 唯一能上色的做法 = 先用该库产出一个合法的 xlsx 骨架,
|
||||||
|
* 再解压 → 改 `xl/styles.xml`(追加 fills + cellXfs)→ 重建 `sheetData`(写 `s=`)
|
||||||
|
* → 按原始压缩字节重新打包。
|
||||||
|
*
|
||||||
|
* 🔴 零新依赖:zip 读写与 XML 片段工具全部复用 `lib/publicitySheet.js`
|
||||||
|
* (该模块只用 Node 内置 zlib + 自实现 CRC32)⇒ 生产上线**不需要 npm install**,
|
||||||
|
* 也就不必为此停机。
|
||||||
|
*
|
||||||
|
* 🔴 全量重建 sheetData 而不是「只补 s 属性」:该库把字符串写成 `t="str"` + `<v>文本</v>`
|
||||||
|
* (这是「公式字符串」语义,无 `<f>` 时并不规范)。既然要逐行改样式,
|
||||||
|
* 索性用 `cellXml()` 以 `inlineStr` 重建每一行 ⇒ 顺带消掉这个隐患,
|
||||||
|
* 也保证「整行」A..H 每格都存在(该库会跳过空值单元格,底色会断成小块)。
|
||||||
|
*
|
||||||
|
* 🔴 分数口径:**必须由调用方传入实时口径**(客观题分 + 大题分)。
|
||||||
|
* `contestants.score` 只有管理员「成绩管理」里保存过分数后才有值,
|
||||||
|
* 考试刚结束恒为 0 —— 直接读它会导出「页面 78 分、表格 0 分」的假表。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const XLSX = require('@e965/xlsx');
|
||||||
|
const {
|
||||||
|
readZip,
|
||||||
|
writeZip,
|
||||||
|
entryText,
|
||||||
|
replaceEntryText,
|
||||||
|
cellXml,
|
||||||
|
} = require('./publicitySheet');
|
||||||
|
|
||||||
|
// ==================== 常量 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表头(6 列,顺序即写入顺序)。
|
||||||
|
*
|
||||||
|
* 🔴 R220-C 用户裁定:本表**按客观题分筛选**,故「大题分」「总分」两列不出现。
|
||||||
|
* 删掉总分列有个强连带约束:底色/名次若仍按总分算,用户看不到总分就无法
|
||||||
|
* 解释某行为什么是绿的 ⇒ 排序键、并列判据、区间判定**三者必须同为客观题分**。
|
||||||
|
*/
|
||||||
|
const HEADERS = ['名次', '小组编号', '学校', '参赛选手', '人数', '客观题分'];
|
||||||
|
|
||||||
|
/** 6 个数据列 */
|
||||||
|
const DATA_COLS = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||||
|
|
||||||
|
/** 列宽(字符数) */
|
||||||
|
const COL_WIDTHS = [
|
||||||
|
{ wch: 7 }, // 名次
|
||||||
|
{ wch: 18 }, // 小组编号
|
||||||
|
{ wch: 34 }, // 学校
|
||||||
|
{ wch: 34 }, // 参赛选手
|
||||||
|
{ wch: 7 }, // 人数
|
||||||
|
{ wch: 11 }, // 客观题分
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 默认统计口径:前 N 名 */
|
||||||
|
const DEFAULT_TOP_N = 80;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客观题满分(R220-D 用户补充的事实)。
|
||||||
|
*
|
||||||
|
* ⚠️ 它**只用于说明行文字**,不参与任何判定:
|
||||||
|
* 区间阈值仍是用户裁定的 60 / 50(70 分制下 60 分 = 86%,属高分段)。
|
||||||
|
* ⛔ 不要因为满分变化就自动缩放阈值 —— 那是用户裁定过的业务规则。
|
||||||
|
* 若某次考试的满分不是 70,由调用方传 fullScore 覆盖即可。
|
||||||
|
*/
|
||||||
|
const DEFAULT_FULL_SCORE = 70;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三个分数区间的**单一判定源**。
|
||||||
|
* 顺序即优先级:先 ≥60,再 ≥50,其余归红。
|
||||||
|
* 边界按用户裁定:≥60 绿 / 50–59.99 黄 / <50 红。
|
||||||
|
*
|
||||||
|
* 🔴 判定输入是**客观题分**(examScore),不是总分(R220-C 用户裁定)。
|
||||||
|
* 阈值本身未变,只是被量的对象换了 —— 因为表格里已无总分列。
|
||||||
|
*/
|
||||||
|
const BANDS = [
|
||||||
|
{ key: 'green', label: '≥60 分(绿)', min: 60, rgb: 'FFC6EFCE' },
|
||||||
|
{ key: 'yellow', label: '50–59.99 分(黄)', min: 50, rgb: 'FFFFEB9C' },
|
||||||
|
{ key: 'red', label: '<50 分(红)', min: -Infinity, rgb: 'FFFFC7CE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 表头底色(灰) */
|
||||||
|
const HEADER_RGB = 'FFD9D9D9';
|
||||||
|
|
||||||
|
/** 前 N 名「红框」的线色(R220-D) */
|
||||||
|
const RED_BORDER_RGB = 'FFFF0000';
|
||||||
|
|
||||||
|
// ==================== 纯函数层 ====================
|
||||||
|
|
||||||
|
/** 转数字,非法值归 0 */
|
||||||
|
function toNum(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保留两位小数,消除浮点噪声(如 59.99999999) */
|
||||||
|
function round2(v) {
|
||||||
|
return Math.round(toNum(v) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分数区间判定(唯一入口)。
|
||||||
|
* @param {number} score 小组**客观题分**(examScore)
|
||||||
|
* @returns {'green'|'yellow'|'red'}
|
||||||
|
*/
|
||||||
|
function scoreBand(score) {
|
||||||
|
const n = toNum(score);
|
||||||
|
for (const b of BANDS) {
|
||||||
|
if (n >= b.min) return b.key;
|
||||||
|
}
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名次编排:按**客观题分**(examScore)降序,同分并列(1、2、2、4)。
|
||||||
|
*
|
||||||
|
* 🔴 排序键 = 客观题分,与「区间底色判定」同源(R220-C 用户裁定)。
|
||||||
|
* ⛔ 不要改成 total:表格里已无总分列,按总分排序会让名次与可见分数矛盾
|
||||||
|
* (出现「客观题 90 分排第 5、客观题 70 分排第 1」这种用户无法理解的结果)。
|
||||||
|
*
|
||||||
|
* 并列组内的先后顺序用「人数升序 → 小组编号升序」定,纯为了结果可复现,
|
||||||
|
* 不影响名次数值本身。
|
||||||
|
*
|
||||||
|
* 注:返回项里仍保留 wordScore / total 字段供内部排查与既有断言使用,
|
||||||
|
* 但 **不写入表格**(HEADERS 只有 6 列)。
|
||||||
|
*
|
||||||
|
* @param {Array<Object>} teams 每项 {teamId, org, players, memberCount, examScore, wordScore, total}
|
||||||
|
* @returns {Array<Object>} 追加了 rank 字段的新数组(不改入参)
|
||||||
|
*/
|
||||||
|
function buildRankRows(teams) {
|
||||||
|
const list = (teams || []).map(t => ({
|
||||||
|
teamId: t && t.teamId != null ? String(t.teamId) : '',
|
||||||
|
org: t && t.org != null ? String(t.org) : '',
|
||||||
|
players: t && t.players != null ? String(t.players) : '',
|
||||||
|
memberCount: Math.trunc(toNum(t && t.memberCount)),
|
||||||
|
examScore: round2(t && t.examScore),
|
||||||
|
wordScore: round2(t && t.wordScore),
|
||||||
|
total: round2(t && t.total),
|
||||||
|
}));
|
||||||
|
|
||||||
|
list.sort((a, b) => {
|
||||||
|
if (b.examScore !== a.examScore) return b.examScore - a.examScore;
|
||||||
|
if (a.memberCount !== b.memberCount) return a.memberCount - b.memberCount;
|
||||||
|
return a.teamId < b.teamId ? -1 : (a.teamId > b.teamId ? 1 : 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
let prevScore = null;
|
||||||
|
let prevRank = 0;
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const cur = list[i].examScore;
|
||||||
|
if (prevScore !== null && cur === prevScore) {
|
||||||
|
list[i].rank = prevRank; // 并列:沿用上一个名次
|
||||||
|
} else {
|
||||||
|
list[i].rank = i + 1; // 跳号:1、2、2、4
|
||||||
|
prevScore = cur;
|
||||||
|
prevRank = list[i].rank;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三区间小组数统计,两套口径(全量 / 全量排名前 N)。
|
||||||
|
*
|
||||||
|
* 🔴 「前 N 名」= 全部小组降序排序后**连续**取前 N 个(跨区间截取),
|
||||||
|
* ⛔ 不是「名次 ≤ N」,⛔ 也不是「每个区间各取前 N 个」。
|
||||||
|
* 保证该列合计恒 = min(总数, N),与「筛选出排名靠前的 80 个小组」的口径一致。
|
||||||
|
* selfCheck() 里有对应断言,误实现成区间各取会在自检阶段直接抛错。
|
||||||
|
*
|
||||||
|
* @param {Array<Object>} rows buildRankRows 的产物
|
||||||
|
* @param {number} topN 默认 80
|
||||||
|
*/
|
||||||
|
function buildStats(rows, topN) {
|
||||||
|
const n = Math.max(0, Math.trunc(toNum(topN == null ? DEFAULT_TOP_N : topN)));
|
||||||
|
const all = rows || [];
|
||||||
|
const top = all.slice(0, n);
|
||||||
|
const countOf = arr => {
|
||||||
|
const c = { green: 0, yellow: 0, red: 0 };
|
||||||
|
// 区间判定与排序键同源:客观题分(R220-C)
|
||||||
|
for (const r of arr) c[scoreBand(r.examScore)]++;
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
topN: n,
|
||||||
|
total: all.length,
|
||||||
|
topTotal: top.length,
|
||||||
|
all: countOf(all),
|
||||||
|
top: countOf(top),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计块下方的口径说明文字。
|
||||||
|
*
|
||||||
|
* 🔴 为什么需要它(R220 用户反馈):当小组总数 ≤ topN 时,「全量小组」与
|
||||||
|
* 「前N名(总排名)」两列数字必然完全相同 —— 用户看到 7 / 7 会合理怀疑
|
||||||
|
* 「是不是每个区间各取了 7 个」。列名再清楚也压不住这种猜疑,
|
||||||
|
* 干脆用一行文字把口径写死,同时把「为何两列一样」当场解释掉。
|
||||||
|
*/
|
||||||
|
function buildScopeNote(stats, fullScore) {
|
||||||
|
const n = stats.topN;
|
||||||
|
const full = fullScore == null ? DEFAULT_FULL_SCORE : fullScore;
|
||||||
|
const head = `说明:本表按客观题分(满分 ${full} 分)降序排列,名次、区间底色与红框范围均以客观题分为准;`;
|
||||||
|
const boxed = Math.min(n, stats.total);
|
||||||
|
const tail = `表格中前 ${boxed} 行已用红色外框圈出(跨区间连续,行间不画横线,以便看成一个整体)。`;
|
||||||
|
if (stats.total <= n) {
|
||||||
|
return `${head}本次共 ${stats.total} 个小组,未超过 ${n} 个,故「前${n}名(总排名)」列等于全量,两列数字相同属正常。`
|
||||||
|
+ `前${n}名 = 全部小组按客观题分降序排列后连续取前 ${n} 个,不是各分数区间分别取前 ${n} 个。${tail}`;
|
||||||
|
}
|
||||||
|
return `${head}「前${n}名(总排名)」= 全部 ${stats.total} 个小组按客观题分降序排列后连续取前 ${n} 个,可跨分数区间截取;`
|
||||||
|
+ `不是各分数区间分别取前 ${n} 个(若按区间各取,合计会远超 ${n})。${tail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== styles.xml 手术 ====================
|
||||||
|
|
||||||
|
/** 取某个元素块内的直接子元素个数(按标签名首段统计) */
|
||||||
|
function countChildren(inner, tag) {
|
||||||
|
const re = new RegExp(`<${tag}\\b`, 'g');
|
||||||
|
return (inner.match(re) || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 styles.xml 末尾追加「表头灰底 + 三档底色 + 前 N 名红框」的
|
||||||
|
* fills / fonts / borders / cellXfs。
|
||||||
|
*
|
||||||
|
* ⚠️ 不假定基线 count 的具体数值:一律按**实际子元素个数**取索引并回写 count,
|
||||||
|
* 这样即使将来 @e965/xlsx 版本升级改了默认 fills 数量也不会把索引错位。
|
||||||
|
*
|
||||||
|
* 🔴 红框为什么要 4 个 border + 12 个 xf(R220-D):
|
||||||
|
* 用户要「前 80 个小组被一个红框整体圈住」,而不是每行各画一个框。
|
||||||
|
* 单格边框只能表达「这条边上有没有线」,所以整体外框得靠**按行位置分工**拼出来:
|
||||||
|
* 首行 → 上边 + 左右;中间行 → 只有左右;末行 → 下边 + 左右。
|
||||||
|
* 每行还有 3 种底色,故 3 底色 × 4 位置(含「只有 1 行」的四边全画)= 12 个 xf。
|
||||||
|
* 若改成「每行四边全画」,相邻行之间的红线会叠成红色网格,视觉上不再是「框选」。
|
||||||
|
*
|
||||||
|
* @param {string} xml styles.xml 原文
|
||||||
|
* @returns {{ xml: string, style: Object }} style 含 header/green/yellow/red 与 box[位置][颜色]
|
||||||
|
*/
|
||||||
|
function patchStyles(xml) {
|
||||||
|
const fillsRe = /<fills\b([^>]*)>([\s\S]*?)<\/fills>/;
|
||||||
|
const fontsRe = /<fonts\b([^>]*)>([\s\S]*?)<\/fonts>/;
|
||||||
|
const bordersRe = /<borders\b([^>]*)>([\s\S]*?)<\/borders>/;
|
||||||
|
const xfsRe = /<cellXfs\b([^>]*)>([\s\S]*?)<\/cellXfs>/;
|
||||||
|
|
||||||
|
const fillsM = fillsRe.exec(xml);
|
||||||
|
const fontsM = fontsRe.exec(xml);
|
||||||
|
const bordersM = bordersRe.exec(xml);
|
||||||
|
const xfsM = xfsRe.exec(xml);
|
||||||
|
if (!fillsM) throw new Error('styles.xml 缺少 <fills>');
|
||||||
|
if (!fontsM) throw new Error('styles.xml 缺少 <fonts>');
|
||||||
|
if (!bordersM) throw new Error('styles.xml 缺少 <borders>');
|
||||||
|
if (!xfsM) throw new Error('styles.xml 缺少 <cellXfs>');
|
||||||
|
|
||||||
|
const baseFillCount = countChildren(fillsM[2], 'fill');
|
||||||
|
const baseFontCount = countChildren(fontsM[2], 'font');
|
||||||
|
const baseBorderCount = countChildren(bordersM[2], 'border');
|
||||||
|
const baseXfCount = countChildren(xfsM[2], 'xf');
|
||||||
|
if (baseFillCount < 2) throw new Error(`styles.xml fills 基线异常(${baseFillCount})`);
|
||||||
|
if (baseFontCount < 1) throw new Error(`styles.xml fonts 基线异常(${baseFontCount})`);
|
||||||
|
if (baseXfCount < 1) throw new Error(`styles.xml cellXfs 基线异常(${baseXfCount})`);
|
||||||
|
|
||||||
|
// ---- fills:表头灰 + 三档底色 ----
|
||||||
|
const solid = rgb => `<fill><patternFill patternType="solid"><fgColor rgb="${rgb}"/><bgColor indexed="64"/></patternFill></fill>`;
|
||||||
|
const addedFills = [HEADER_RGB, ...BANDS.map(b => b.rgb)].map(solid).join('');
|
||||||
|
const fHeader = baseFillCount;
|
||||||
|
const fBand = {};
|
||||||
|
BANDS.forEach((b, i) => { fBand[b.key] = baseFillCount + 1 + i; });
|
||||||
|
|
||||||
|
let out = xml.replace(fillsM[0], () =>
|
||||||
|
`<fills count="${baseFillCount + 1 + BANDS.length}">${fillsM[2]}${addedFills}</fills>`);
|
||||||
|
|
||||||
|
// ---- fonts:表头加粗 ----
|
||||||
|
const boldFontId = baseFontCount;
|
||||||
|
const boldFont = '<font><b/><sz val="12"/><color theme="1"/><name val="Calibri"/><family val="2"/><scheme val="minor"/></font>';
|
||||||
|
out = out.replace(fontsM[0], () =>
|
||||||
|
`<fonts count="${baseFontCount + 1}">${fontsM[2]}${boldFont}</fonts>`);
|
||||||
|
|
||||||
|
// ---- borders:4 种红框(顺序必须是 left/right/top/bottom/diagonal)----
|
||||||
|
const side = name => `<${name} style="medium"><color rgb="${RED_BORDER_RGB}"/></${name}>`;
|
||||||
|
const empty = name => `<${name}/>`;
|
||||||
|
const borderXml = (top, bottom) =>
|
||||||
|
`<border>${side('left')}${side('right')}${top ? side('top') : empty('top')}${bottom ? side('bottom') : empty('bottom')}<diagonal/></border>`;
|
||||||
|
|
||||||
|
// 位置 → [是否有上边, 是否有下边]
|
||||||
|
const BOX_POS = {
|
||||||
|
single: [true, true], // 前 N 名只有 1 行时,这一个格子四边都要有
|
||||||
|
top: [true, false],
|
||||||
|
mid: [false, false],
|
||||||
|
bot: [false, true],
|
||||||
|
};
|
||||||
|
const BOX_KEYS = ['single', 'top', 'mid', 'bot'];
|
||||||
|
const addedBorders = BOX_KEYS.map(k => borderXml(BOX_POS[k][0], BOX_POS[k][1])).join('');
|
||||||
|
const bId = {};
|
||||||
|
BOX_KEYS.forEach((k, i) => { bId[k] = baseBorderCount + i; });
|
||||||
|
|
||||||
|
out = out.replace(bordersM[0], () =>
|
||||||
|
`<borders count="${baseBorderCount + BOX_KEYS.length}">${bordersM[2]}${addedBorders}</borders>`);
|
||||||
|
|
||||||
|
// ---- cellXfs ----
|
||||||
|
const xf = (fillId, opt) => {
|
||||||
|
const o = opt || {};
|
||||||
|
const borderId = o.borderId == null ? 0 : o.borderId;
|
||||||
|
const attrs = [
|
||||||
|
'numFmtId="0"',
|
||||||
|
`fontId="${o.bold ? boldFontId : 0}"`,
|
||||||
|
`fillId="${fillId}"`,
|
||||||
|
`borderId="${borderId}"`,
|
||||||
|
'xfId="0"',
|
||||||
|
];
|
||||||
|
if (fillId !== 0) attrs.push('applyFill="1"');
|
||||||
|
if (o.bold) attrs.push('applyFont="1"');
|
||||||
|
if (borderId !== 0) attrs.push('applyBorder="1"');
|
||||||
|
return `<xf ${attrs.join(' ')}/>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addedXfs = [xf(fHeader, { bold: true })];
|
||||||
|
// 纯底色(不在前 N 名里的行)
|
||||||
|
BANDS.forEach(b => addedXfs.push(xf(fBand[b.key])));
|
||||||
|
// 前 N 名:底色 × 位置
|
||||||
|
const xfIndex = {};
|
||||||
|
BOX_KEYS.forEach(k => {
|
||||||
|
xfIndex[k] = {};
|
||||||
|
BANDS.forEach(b => {
|
||||||
|
xfIndex[k][b.key] = baseXfCount + addedXfs.length;
|
||||||
|
addedXfs.push(xf(fBand[b.key], { borderId: bId[k] }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
header: baseXfCount,
|
||||||
|
box: xfIndex,
|
||||||
|
};
|
||||||
|
BANDS.forEach(b => { style[b.key] = baseXfCount + 1 + BANDS.indexOf(b); });
|
||||||
|
|
||||||
|
out = out.replace(xfsM[0], () =>
|
||||||
|
`<cellXfs count="${baseXfCount + addedXfs.length}">${xfsM[2]}${addedXfs.join('')}</cellXfs>`);
|
||||||
|
|
||||||
|
return { xml: out, style };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== sheetData 重建 ====================
|
||||||
|
|
||||||
|
function rowXml(r, cells) {
|
||||||
|
return `<row r="${r}">${cells.join('')}</row>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成完整的 sheetData(含表头行、数据行、空行、统计块)。
|
||||||
|
*
|
||||||
|
* 行号布局:
|
||||||
|
* 1 表头
|
||||||
|
* 2 .. 1+N 数据(N = 小组数;N=0 时数据区为空)
|
||||||
|
* 2+N 空行
|
||||||
|
* 3+N 统计表头(区间统计 / 全量小组 / 前 K 名(总排名))
|
||||||
|
* 4+N .. 6+N 三个区间
|
||||||
|
* 7+N 合计
|
||||||
|
* 8+N 口径说明(一行文字,解释「前 K 名」是全量跨区间截取)
|
||||||
|
*
|
||||||
|
* @returns {{ xml:string, lastRow:number }}
|
||||||
|
*/
|
||||||
|
function buildSheetData(rows, stats, style, fullScore) {
|
||||||
|
const out = [];
|
||||||
|
const R = k => k;
|
||||||
|
|
||||||
|
// 表头
|
||||||
|
out.push(rowXml(R(1), HEADERS.map((h, i) => cellXml(DATA_COLS[i], 1, style.header, h))));
|
||||||
|
|
||||||
|
// 数据行(6 列:名次/小组编号/学校/参赛选手/人数/客观题分)
|
||||||
|
// 🔴 底色取值按 examScore 判定 —— 与排序键同源,否则「分数高低」与「颜色」矛盾
|
||||||
|
// 🔴 前 N 行额外套红框:红框范围与统计块「前N名」列**同一口径**(排序后前 N 行),
|
||||||
|
// 保证「框住的」和「统计里算的」永远一致,不会一个框 80 行、一个算 80 组。
|
||||||
|
const boxCount = Math.min(stats.topN, rows.length);
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const r = i + 2;
|
||||||
|
const row = rows[i];
|
||||||
|
const band = scoreBand(row.examScore);
|
||||||
|
let s;
|
||||||
|
if (i < boxCount) {
|
||||||
|
// 首行画上边、末行画下边、中间只画左右 ⇒ 拼成一个整体外框(行间无横线)
|
||||||
|
const pos = boxCount === 1 ? 'single' : (i === 0 ? 'top' : (i === boxCount - 1 ? 'bot' : 'mid'));
|
||||||
|
s = style.box[pos][band];
|
||||||
|
} else {
|
||||||
|
s = style[band];
|
||||||
|
}
|
||||||
|
out.push(rowXml(r, [
|
||||||
|
cellXml('A', r, s, row.rank),
|
||||||
|
cellXml('B', r, s, row.teamId),
|
||||||
|
cellXml('C', r, s, row.org),
|
||||||
|
cellXml('D', r, s, row.players),
|
||||||
|
cellXml('E', r, s, row.memberCount),
|
||||||
|
cellXml('F', r, s, row.examScore),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = rows.length;
|
||||||
|
const blankRow = n + 2;
|
||||||
|
const statHeadRow = n + 3;
|
||||||
|
|
||||||
|
// 统计块表头
|
||||||
|
// 🔴 列标题曾写「其中前80名」,被用户读成「各个区间其中前80名」(R220 反馈)。
|
||||||
|
// 实际口径是**全量排名**前 N,故列名改为「前N名(总排名)」消除歧义。
|
||||||
|
out.push(rowXml(statHeadRow, [
|
||||||
|
cellXml('A', statHeadRow, style.header, '区间统计'),
|
||||||
|
cellXml('B', statHeadRow, style.header, '全量小组'),
|
||||||
|
cellXml('C', statHeadRow, style.header, `前${stats.topN}名(总排名)`),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// 三个区间(A 列带该区间的底色,作为图例)
|
||||||
|
BANDS.forEach((b, i) => {
|
||||||
|
const r = statHeadRow + 1 + i;
|
||||||
|
out.push(rowXml(r, [
|
||||||
|
cellXml('A', r, style[b.key], b.label),
|
||||||
|
cellXml('B', r, undefined, stats.all[b.key]),
|
||||||
|
cellXml('C', r, undefined, stats.top[b.key]),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 合计
|
||||||
|
const sumRow = statHeadRow + 1 + BANDS.length;
|
||||||
|
out.push(rowXml(sumRow, [
|
||||||
|
cellXml('A', sumRow, style.header, '合计'),
|
||||||
|
cellXml('B', sumRow, undefined, stats.total),
|
||||||
|
cellXml('C', sumRow, undefined, stats.topTotal),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// 口径说明行(无底色,A 列文字自然溢出到右侧空列)
|
||||||
|
const noteRow = sumRow + 1;
|
||||||
|
out.push(rowXml(noteRow, [
|
||||||
|
cellXml('A', noteRow, undefined, buildScopeNote(stats, fullScore)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return { xml: `<sheetData>${out.join('')}</sheetData>`, lastRow: noteRow, blankRow };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 组装 ====================
|
||||||
|
|
||||||
|
/** 造一个最小合法 xlsx 骨架(表头一行的空表),后续全部靠 zip 层替换 */
|
||||||
|
function buildBaseBuffer(sheetName) {
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet([HEADERS.slice()]);
|
||||||
|
ws['!cols'] = COL_WIDTHS.map(c => ({ wch: c.wch }));
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, String(sheetName || 'Sheet1').slice(0, 31));
|
||||||
|
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 zip 条目里取某文件文本 */
|
||||||
|
function textOf(entries, name) {
|
||||||
|
const e = entries.find(x => x.name === name);
|
||||||
|
if (!e) throw new Error(`xlsx 缺少条目 ${name}`);
|
||||||
|
return entryText(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主入口:生成成绩排名表 xlsx。
|
||||||
|
*
|
||||||
|
* @param {Object} opt
|
||||||
|
* @param {Array<Object>} opt.teams 原始小组数组(未经排序)
|
||||||
|
* @param {string} [opt.sheetName] 工作表名
|
||||||
|
* @param {number} [opt.topN=80] 统计口径里的「前 N 名」
|
||||||
|
* @returns {{ buffer: Buffer, colored: boolean, rows: Array<Object>, stats: Object, lastRow: number }}
|
||||||
|
* colored=false 表示上色链路失败已自动降级为无色版
|
||||||
|
*/
|
||||||
|
function buildXlsx(opt) {
|
||||||
|
const options = opt || {};
|
||||||
|
const rows = buildRankRows(options.teams);
|
||||||
|
const topN = options.topN == null ? DEFAULT_TOP_N : options.topN;
|
||||||
|
const fullScore = options.fullScore == null ? DEFAULT_FULL_SCORE : options.fullScore;
|
||||||
|
const stats = buildStats(rows, topN);
|
||||||
|
const sheetName = options.sheetName || '成绩排名';
|
||||||
|
const base = buildBaseBuffer(sheetName);
|
||||||
|
|
||||||
|
// ---- 第一路:带底色 ----
|
||||||
|
try {
|
||||||
|
const entries = readZip(base);
|
||||||
|
|
||||||
|
const stylesPatched = patchStyles(textOf(entries, 'xl/styles.xml'));
|
||||||
|
let out = replaceEntryText(entries, 'xl/styles.xml', stylesPatched.xml);
|
||||||
|
|
||||||
|
const built = buildSheetData(rows, stats, stylesPatched.style, fullScore);
|
||||||
|
let sheetXml = textOf(out, 'xl/worksheets/sheet1.xml');
|
||||||
|
|
||||||
|
const sdRe = /<sheetData\b[^>]*>[\s\S]*?<\/sheetData>/;
|
||||||
|
if (!sdRe.test(sheetXml)) throw new Error('sheet1.xml 缺少 <sheetData>');
|
||||||
|
sheetXml = sheetXml.replace(sdRe, built.xml);
|
||||||
|
|
||||||
|
// dimension 必须同步,否则 Excel 的滚动区/打印区按旧范围截断
|
||||||
|
sheetXml = sheetXml.replace(
|
||||||
|
/<dimension\b[^>]*\bref="[^"]*"/,
|
||||||
|
`<dimension ref="A1:${DATA_COLS[DATA_COLS.length - 1]}${built.lastRow}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 骨架带出来的 ignoredErrors 指向 A1:H1,范围已失真,直接删掉避免误报
|
||||||
|
sheetXml = sheetXml.replace(/<ignoredErrors\b[^>]*\/>/g, '')
|
||||||
|
.replace(/<ignoredErrors\b[^>]*>[\s\S]*?<\/ignoredErrors>/g, '');
|
||||||
|
|
||||||
|
out = replaceEntryText(out, 'xl/worksheets/sheet1.xml', sheetXml);
|
||||||
|
|
||||||
|
const buffer = writeZip(out);
|
||||||
|
selfCheck(buffer, rows, stats, true);
|
||||||
|
|
||||||
|
return { buffer, colored: true, rows, stats, lastRow: built.lastRow };
|
||||||
|
} catch (err) {
|
||||||
|
// ---- 第二路:降级无色 ----
|
||||||
|
const buffer = buildPlain(rows, stats, sheetName, fullScore);
|
||||||
|
selfCheck(buffer, rows, stats, false);
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
colored: false,
|
||||||
|
rows,
|
||||||
|
stats,
|
||||||
|
lastRow: rows.length + 8,
|
||||||
|
degradeReason: err && err.message ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 降级版:不上任何底色,但数据/表头/统计块与带色版完全一致 */
|
||||||
|
function buildPlain(rows, stats, sheetName, fullScore) {
|
||||||
|
const aoa = [HEADERS.slice()];
|
||||||
|
for (const row of rows) {
|
||||||
|
aoa.push([row.rank, row.teamId, row.org, row.players, row.memberCount, row.examScore]);
|
||||||
|
}
|
||||||
|
aoa.push([]);
|
||||||
|
aoa.push(['区间统计', '全量小组', `前${stats.topN}名(总排名)`]);
|
||||||
|
for (const b of BANDS) aoa.push([b.label, stats.all[b.key], stats.top[b.key]]);
|
||||||
|
aoa.push(['合计', stats.total, stats.topTotal]);
|
||||||
|
aoa.push([buildScopeNote(stats, fullScore)]);
|
||||||
|
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||||||
|
ws['!cols'] = COL_WIDTHS.map(c => ({ wch: c.wch }));
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, String(sheetName || '成绩排名').slice(0, 31));
|
||||||
|
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自检:回读产物,确认「能读通 + 行数对 + 首行值对 +(带色版)styles 里真有底色」。
|
||||||
|
* 任一条不满足就抛错,由上层降级。
|
||||||
|
*/
|
||||||
|
function selfCheck(buffer, rows, stats, expectColor) {
|
||||||
|
const wb = XLSX.read(buffer, { type: 'buffer' });
|
||||||
|
const name = wb.SheetNames[0];
|
||||||
|
const ws = wb.Sheets[name];
|
||||||
|
if (!ws) throw new Error('自检失败:产物无工作表');
|
||||||
|
const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, blankrows: true });
|
||||||
|
if (!aoa.length) throw new Error('自检失败:产物为空');
|
||||||
|
|
||||||
|
const head = aoa[0] || [];
|
||||||
|
for (let i = 0; i < HEADERS.length; i++) {
|
||||||
|
if (String(head[i] == null ? '' : head[i]) !== HEADERS[i]) {
|
||||||
|
throw new Error(`自检失败:表头第 ${i + 1} 列应为「${HEADERS[i]}」,实为「${head[i]}」`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据区行数
|
||||||
|
const dataRows = aoa.slice(1, 1 + rows.length);
|
||||||
|
if (dataRows.length !== rows.length) {
|
||||||
|
throw new Error(`自检失败:数据行数 ${dataRows.length} ≠ 期望 ${rows.length}`);
|
||||||
|
}
|
||||||
|
// 首行关键值抽查(名次 / 小组编号 / 客观题分)
|
||||||
|
if (rows.length) {
|
||||||
|
const first = dataRows[0] || [];
|
||||||
|
if (String(first[0]) !== String(rows[0].rank)) throw new Error('自检失败:首行名次不符');
|
||||||
|
if (String(first[1]) !== String(rows[0].teamId)) throw new Error('自检失败:首行小组编号不符');
|
||||||
|
if (Number(first[5]) !== Number(rows[0].examScore)) throw new Error('自检失败:首行客观题分不符');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔴 列数锁死:本表只出 6 列,「大题分」「总分」不得回流(R220-C 用户裁定)
|
||||||
|
if (HEADERS.length !== 6) throw new Error(`自检失败:表头列数应为 6,实为 ${HEADERS.length}`);
|
||||||
|
for (let i = 6; i < head.length; i++) {
|
||||||
|
if (String(head[i] == null ? '' : head[i]).trim() !== '') {
|
||||||
|
throw new Error(`自检失败:第 ${i + 1} 列应不存在,实为「${head[i]}」`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 排序/名次必须由客观题分驱动:逐行校验「客观题分不得逆序」
|
||||||
|
for (let i = 1; i < rows.length; i++) {
|
||||||
|
if (rows[i - 1].examScore < rows[i].examScore) {
|
||||||
|
throw new Error(`自检失败:第 ${i} 行客观题分逆序(${rows[i - 1].examScore} < ${rows[i].examScore})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 统计块
|
||||||
|
const statHead = aoa[1 + rows.length + 1] || [];
|
||||||
|
if (String(statHead[0] || '') !== '区间统计') throw new Error('自检失败:统计块表头缺失');
|
||||||
|
|
||||||
|
const expectC = `前${stats.topN}名(总排名)`;
|
||||||
|
if (String(statHead[2] || '') !== expectC) {
|
||||||
|
throw new Error(`自检失败:统计块 C 列表头应为「${expectC}」,实为「${statHead[2]}」`);
|
||||||
|
}
|
||||||
|
// 🔴 口径自证:「前N名」列合计必须 = min(总数, N)。
|
||||||
|
// 若被误实现成「各分数区间分别取前 N 个」,总数 > N 时该值会超 N ⇒ 此处直接红。
|
||||||
|
const expectTopTotal = Math.min(stats.total, stats.topN);
|
||||||
|
if (stats.topTotal !== expectTopTotal) {
|
||||||
|
throw new Error(
|
||||||
|
`自检失败:前${stats.topN}名合计 ${stats.topTotal} ≠ min(总数 ${stats.total}, ${stats.topN})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const topSum = stats.top.green + stats.top.yellow + stats.top.red;
|
||||||
|
if (topSum !== stats.topTotal) {
|
||||||
|
throw new Error(`自检失败:三区间前N名之和 ${topSum} ≠ 合计 ${stats.topTotal}`);
|
||||||
|
}
|
||||||
|
for (const b of BANDS) {
|
||||||
|
if (stats.top[b.key] > stats.all[b.key]) {
|
||||||
|
throw new Error(`自检失败:${b.label} 的「前N名」数 ${stats.top[b.key]} 超过全量数 ${stats.all[b.key]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectColor) {
|
||||||
|
const entries = readZip(buffer);
|
||||||
|
const styles = textOf(entries, 'xl/styles.xml');
|
||||||
|
for (const b of BANDS) {
|
||||||
|
if (styles.indexOf(b.rgb) < 0) throw new Error(`自检失败:styles.xml 缺底色 ${b.rgb}`);
|
||||||
|
}
|
||||||
|
const sheetXml = textOf(entries, 'xl/worksheets/sheet1.xml');
|
||||||
|
if (!/\bs="\d+"/.test(sheetXml)) throw new Error('自检失败:sheet1.xml 无任何样式引用');
|
||||||
|
|
||||||
|
// ---- 红框校验(R220-D):从产物反推,不依赖内存里的 style 索引 ----
|
||||||
|
// 先找「含红色 + medium 线宽」的 border 定义,再看哪些 xf 引用了它们,
|
||||||
|
// 最后逐行核对「前 min(topN, 行数) 行必须是红框 xf,其余行必须不是」。
|
||||||
|
const bordersBlock = /<borders\b[^>]*>([\s\S]*?)<\/borders>/.exec(styles);
|
||||||
|
const borderList = bordersBlock ? (bordersBlock[1].match(/<border\b[\s\S]*?<\/border>/g) || []) : [];
|
||||||
|
const redBorderIds = new Set();
|
||||||
|
borderList.forEach((b, i) => {
|
||||||
|
if (b.indexOf(RED_BORDER_RGB) >= 0 && /style="medium"/.test(b)) redBorderIds.add(i);
|
||||||
|
});
|
||||||
|
if (redBorderIds.size === 0) throw new Error('自检失败:styles.xml 里没有红色粗边框定义');
|
||||||
|
|
||||||
|
const xfsBlock = /<cellXfs\b[^>]*>([\s\S]*?)<\/cellXfs>/.exec(styles);
|
||||||
|
const xfList = xfsBlock ? (xfsBlock[1].match(/<xf\b[^>]*\/>/g) || []) : [];
|
||||||
|
const boxXfIds = new Set();
|
||||||
|
xfList.forEach((x, i) => {
|
||||||
|
const mm = /borderId="(\d+)"/.exec(x);
|
||||||
|
if (mm && redBorderIds.has(Number(mm[1]))) boxXfIds.add(i);
|
||||||
|
});
|
||||||
|
if (boxXfIds.size === 0) throw new Error('自检失败:没有 cellXfs 引用红色边框');
|
||||||
|
|
||||||
|
const expectBox = Math.min(stats.topN, rows.length);
|
||||||
|
const styleAt = k => {
|
||||||
|
const rm = new RegExp(`<row r="${k}">([\\s\\S]*?)</row>`).exec(sheetXml);
|
||||||
|
const sm = rm ? /\bs="(\d+)"/.exec(rm[1]) : null;
|
||||||
|
return sm ? Number(sm[1]) : -1;
|
||||||
|
};
|
||||||
|
for (let k = 0; k < rows.length; k++) {
|
||||||
|
const sx = styleAt(k + 2);
|
||||||
|
const hasBox = boxXfIds.has(sx);
|
||||||
|
const want = k < expectBox;
|
||||||
|
if (hasBox !== want) {
|
||||||
|
throw new Error(
|
||||||
|
`自检失败:第 ${k + 2} 行红框归属错误(应为「${want ? '有框' : '无框'}」,`
|
||||||
|
+ `实为「${hasBox ? '有框' : '无框'}」,s=${sx},前 ${expectBox} 行应有框)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rows: aoa.length, sheetName: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
HEADERS,
|
||||||
|
DATA_COLS,
|
||||||
|
COL_WIDTHS,
|
||||||
|
DEFAULT_TOP_N,
|
||||||
|
DEFAULT_FULL_SCORE,
|
||||||
|
BANDS,
|
||||||
|
HEADER_RGB,
|
||||||
|
RED_BORDER_RGB,
|
||||||
|
toNum,
|
||||||
|
round2,
|
||||||
|
scoreBand,
|
||||||
|
buildRankRows,
|
||||||
|
buildStats,
|
||||||
|
buildScopeNote,
|
||||||
|
patchStyles,
|
||||||
|
buildSheetData,
|
||||||
|
buildXlsx,
|
||||||
|
buildPlain,
|
||||||
|
selfCheck,
|
||||||
|
};
|
||||||
287
app/lib/teacherBackfill.js
Normal file
287
app/lib/teacherBackfill.js
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指导教师回填 —— 纯判定层(R176)
|
||||||
|
*
|
||||||
|
* 背景:`contestants` 原本没有任何「指导教师」承载字段,而成绩公示表
|
||||||
|
* (`7-2026一带一路暨金砖大赛成绩公示表-参考模版.xlsx`)第 4 列必须填写。
|
||||||
|
* 报名表里其实有指导教师行,但导入流程按业务规则只导入「参赛选手」身份
|
||||||
|
* (`service/admin.js` 的 IMPORT_IDENTITY),教师行被计入 `ignored` 丢弃。
|
||||||
|
*
|
||||||
|
* 本模块只负责**从报名表二维数组里把指导教师找出来、按小组归并**,
|
||||||
|
* 不碰数据库、不碰文件、不依赖 Egg。落库由 `service/admin.js#backfillTeachers` 完成。
|
||||||
|
*
|
||||||
|
* 🔴 为什么不直接复用既有导入:`importContestants` 对已存在的身份证号一律 skip
|
||||||
|
* (`result.skipped++`),所以「重导一次」不会更新任何字段。回填必须是独立通路。
|
||||||
|
*
|
||||||
|
* 🔴 为什么必须带 group:`teams.team_uid` 仅组内唯一、可跨组重号(实测本科组也有
|
||||||
|
* team_uid=1),只按 teamId 归并会把别的组的教师挂到本组头上。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 教师身份白名单(归一化:去掉所有空白字符后比对) */
|
||||||
|
const TEACHER_IDENTITIES = [
|
||||||
|
'指导教师',
|
||||||
|
'指导老师',
|
||||||
|
'指导专家',
|
||||||
|
'指导教师(专家)',
|
||||||
|
'指导教师(专家)',
|
||||||
|
'指导教师(教练)',
|
||||||
|
'指导教师(教练)',
|
||||||
|
'教练',
|
||||||
|
'导师',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 组别别名 → 系统 key。⚠️ 名字带 TEACHER_ 前缀,避免与 service/admin.js 的 GROUP_ALIAS 撞名 */
|
||||||
|
const TEACHER_GROUP_ALIAS = {
|
||||||
|
中职: 'zhongzhi',
|
||||||
|
中职组: 'zhongzhi',
|
||||||
|
高职: 'gaozhi',
|
||||||
|
高职组: 'gaozhi',
|
||||||
|
本科: 'benke',
|
||||||
|
本科组: 'benke',
|
||||||
|
教师: 'jiaoshi',
|
||||||
|
教师组: 'jiaoshi',
|
||||||
|
zhongzhi: 'zhongzhi',
|
||||||
|
gaozhi: 'gaozhi',
|
||||||
|
benke: 'benke',
|
||||||
|
jiaoshi: 'jiaoshi',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 报名表列名别名(与导入契约同源,但这里只取回填必需的 4 列) */
|
||||||
|
const TEACHER_COLUMN_ALIAS = {
|
||||||
|
name: ['姓名', '全名', 'name'],
|
||||||
|
teamId: ['队伍编号', '队伍ID', '队伍id', 'team_id', 'team_number'],
|
||||||
|
group: ['组别', 'group'],
|
||||||
|
identity: ['身份', 'identity'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW_STATUS = {
|
||||||
|
TEACHER: 'TEACHER',
|
||||||
|
NOT_TEACHER: 'NOT_TEACHER',
|
||||||
|
TEACHER_NO_TEAM: 'TEACHER_NO_TEAM',
|
||||||
|
TEACHER_NO_NAME: 'TEACHER_NO_NAME',
|
||||||
|
TEACHER_NO_GROUP: 'TEACHER_NO_GROUP',
|
||||||
|
BLANK_ROW: 'BLANK_ROW',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去掉所有空白字符(含全角空格、不换行空格、零宽字符)。
|
||||||
|
* ⛔ 不能只 trim:上游表格里「指导 教师」「指导教师」混用很常见,
|
||||||
|
* 只 trim 会把前者判成非教师身份而整行丢掉。
|
||||||
|
*/
|
||||||
|
function squash(v) {
|
||||||
|
if (v === null || v === undefined) return '';
|
||||||
|
return String(v)
|
||||||
|
.replace(/[\u3000\u00a0\u200b\u200c\u200d\ufeff]/g, '')
|
||||||
|
.replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组别归一;无法识别时返回空串(调用方据此判定"组别缺失") */
|
||||||
|
function normalizeGroup(v) {
|
||||||
|
const s = squash(v);
|
||||||
|
if (!s) return '';
|
||||||
|
return TEACHER_GROUP_ALIAS[s] || TEACHER_GROUP_ALIAS[s.toLowerCase()] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否教师身份。归一化后精确命中白名单才算,⛔ 不做包含匹配(避免「非指导教师」被误判) */
|
||||||
|
function isTeacherIdentity(v) {
|
||||||
|
const s = squash(v);
|
||||||
|
if (!s) return false;
|
||||||
|
return TEACHER_IDENTITIES.indexOf(s) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 队伍编号归一:只 trim 首尾空白与零宽字符,⛔ 不折叠大小写(编号大小写不敏感会串组) */
|
||||||
|
function normalizeTeamId(v) {
|
||||||
|
if (v === null || v === undefined) return '';
|
||||||
|
return String(v).replace(/[\u200b\u200c\u200d\ufeff]/g, '').replace(/^[\s\u3000\u00a0]+|[\s\u3000\u00a0]+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在给定行里按别名找列下标,返回 -1 表示未命中 */
|
||||||
|
function findColumn(row, aliases) {
|
||||||
|
if (!Array.isArray(row)) return -1;
|
||||||
|
for (let i = 0; i < row.length; i++) {
|
||||||
|
const cell = squash(row[i]);
|
||||||
|
if (!cell) continue;
|
||||||
|
for (const a of aliases) {
|
||||||
|
if (cell === squash(a)) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析报名表 AOA,提取「小组 → 指导教师姓名列表」。
|
||||||
|
*
|
||||||
|
* @param {Array<Array>} aoa 二维数组(xlsx sheet_to_json header:1 的产物)
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {number} [opts.maxHeaderScan=10] 表头最多向下扫描的行数
|
||||||
|
* @returns {{
|
||||||
|
* ok: boolean, code: string, message: string,
|
||||||
|
* teams: Array<{group: string, teamId: string, names: string[], rowNumbers: number[]}>,
|
||||||
|
* stats: Object, errors: Array<Object>
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
function extractTeachers(aoa, opts) {
|
||||||
|
const maxHeaderScan = (opts && opts.maxHeaderScan) || 10;
|
||||||
|
const empty = { teams: [], stats: { rows: 0, teachers: 0, notTeachers: 0, blank: 0 }, errors: [] };
|
||||||
|
|
||||||
|
if (!Array.isArray(aoa) || aoa.length === 0) {
|
||||||
|
return Object.assign({ ok: false, code: 'EMPTY_FILE', message: '文件内容为空' }, empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 1) 定位表头 ----
|
||||||
|
let headerRowIdx = -1;
|
||||||
|
let colName = -1;
|
||||||
|
let colTeam = -1;
|
||||||
|
let colGroup = -1;
|
||||||
|
let colIdentity = -1;
|
||||||
|
for (let i = 0; i < Math.min(aoa.length, maxHeaderScan); i++) {
|
||||||
|
const r = aoa[i];
|
||||||
|
const cn = findColumn(r, TEACHER_COLUMN_ALIAS.name);
|
||||||
|
const ci = findColumn(r, TEACHER_COLUMN_ALIAS.identity);
|
||||||
|
if (cn >= 0 && ci >= 0) {
|
||||||
|
headerRowIdx = i;
|
||||||
|
colName = cn;
|
||||||
|
colIdentity = ci;
|
||||||
|
colTeam = findColumn(r, TEACHER_COLUMN_ALIAS.teamId);
|
||||||
|
colGroup = findColumn(r, TEACHER_COLUMN_ALIAS.group);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (headerRowIdx < 0) {
|
||||||
|
return Object.assign({
|
||||||
|
ok: false,
|
||||||
|
code: 'HEADER_NOT_FOUND',
|
||||||
|
message: '前 10 行内没有同时包含「姓名」与「身份」的表头,请确认上传的是报名表',
|
||||||
|
}, empty);
|
||||||
|
}
|
||||||
|
if (colTeam < 0) {
|
||||||
|
return Object.assign({
|
||||||
|
ok: false,
|
||||||
|
code: 'TEAM_COLUMN_MISSING',
|
||||||
|
message: '报名表缺少「队伍编号」列,无法把指导教师归到具体小组',
|
||||||
|
}, empty);
|
||||||
|
}
|
||||||
|
if (colGroup < 0) {
|
||||||
|
return Object.assign({
|
||||||
|
ok: false,
|
||||||
|
code: 'GROUP_COLUMN_MISSING',
|
||||||
|
message: '报名表缺少「组别」列,无法限定同组(team_uid 跨组重号,不能只按编号归并)',
|
||||||
|
}, empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2) 逐行收集 ----
|
||||||
|
const stats = { rows: 0, teachers: 0, notTeachers: 0, blank: 0 };
|
||||||
|
const errors = [];
|
||||||
|
// key = group + '\u0000' + teamId,用不可见分隔符避免与编号里的字符冲突
|
||||||
|
const bucket = new Map();
|
||||||
|
|
||||||
|
for (let i = headerRowIdx + 1; i < aoa.length; i++) {
|
||||||
|
const r = aoa[i] || [];
|
||||||
|
stats.rows++;
|
||||||
|
const rowNumber = i + 1; // 1 基,与 Excel 行号一致
|
||||||
|
|
||||||
|
const nameRaw = colName >= 0 ? r[colName] : '';
|
||||||
|
const identityRaw = colIdentity >= 0 ? r[colIdentity] : '';
|
||||||
|
const allBlank = r.every(c => squash(c) === '');
|
||||||
|
if (allBlank) {
|
||||||
|
stats.blank++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTeacherIdentity(identityRaw)) {
|
||||||
|
stats.notTeachers++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = String(nameRaw === null || nameRaw === undefined ? '' : nameRaw).trim();
|
||||||
|
if (!name) {
|
||||||
|
stats.teachers++;
|
||||||
|
errors.push({ row: rowNumber, status: ROW_STATUS.TEACHER_NO_NAME, message: '身份为导师但姓名为空,已跳过' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const group = normalizeGroup(colGroup >= 0 ? r[colGroup] : '');
|
||||||
|
if (!group) {
|
||||||
|
stats.teachers++;
|
||||||
|
errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
status: ROW_STATUS.TEACHER_NO_GROUP,
|
||||||
|
message: `指导教师「${name}」所在行组别为空或无法识别,已跳过(缺少 group 无法安全归组)`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const teamId = normalizeTeamId(colTeam >= 0 ? r[colTeam] : '');
|
||||||
|
if (!teamId) {
|
||||||
|
stats.teachers++;
|
||||||
|
errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
status: ROW_STATUS.TEACHER_NO_TEAM,
|
||||||
|
message: `指导教师「${name}」所在行队伍编号为空,已跳过(无法定位到具体小组)`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.teachers++;
|
||||||
|
const key = `${group}\u0000${teamId}`;
|
||||||
|
let item = bucket.get(key);
|
||||||
|
if (!item) {
|
||||||
|
item = { group, teamId, names: [], rowNumbers: [] };
|
||||||
|
bucket.set(key, item);
|
||||||
|
}
|
||||||
|
if (item.names.indexOf(name) < 0) item.names.push(name);
|
||||||
|
item.rowNumbers.push(rowNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams = Array.from(bucket.values());
|
||||||
|
|
||||||
|
if (teams.length === 0) {
|
||||||
|
return Object.assign({
|
||||||
|
ok: false,
|
||||||
|
code: 'NO_TEACHER_FOUND',
|
||||||
|
message: stats.teachers === 0
|
||||||
|
? '报名表里没有身份为「指导教师」的行(请确认「身份」列的写法)'
|
||||||
|
: '识别到指导教师行,但都缺少组别或队伍编号,无法归组',
|
||||||
|
}, { stats, errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.assign({ ok: true, code: 'OK', message: `识别到 ${teams.length} 个小组的指导教师` }, { teams, stats, errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把同一小组的多个指导教师姓名合成一个展示串。
|
||||||
|
* ⛔ 去重保序:报名表里同一教师可能重复出现(同一人被登记两次),
|
||||||
|
* 直接 join 会得到「张三、张三」。
|
||||||
|
*/
|
||||||
|
function joinNames(names) {
|
||||||
|
if (!Array.isArray(names)) return '';
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const n of names) {
|
||||||
|
const s = String(n === null || n === undefined ? '' : n).trim();
|
||||||
|
if (!s || seen.has(s)) continue;
|
||||||
|
seen.add(s);
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
return out.join('、');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1 基数组是报名表原始行号,直接透出(调用方拼提示用) */
|
||||||
|
function formatRowNumbers(nums) {
|
||||||
|
if (!Array.isArray(nums) || nums.length === 0) return '';
|
||||||
|
return nums.join('、');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TEACHER_IDENTITIES,
|
||||||
|
TEACHER_GROUP_ALIAS,
|
||||||
|
TEACHER_COLUMN_ALIAS,
|
||||||
|
ROW_STATUS,
|
||||||
|
squash,
|
||||||
|
normalizeGroup,
|
||||||
|
normalizeTeamId,
|
||||||
|
isTeacherIdentity,
|
||||||
|
findColumn,
|
||||||
|
extractTeachers,
|
||||||
|
joinNames,
|
||||||
|
formatRowNumbers,
|
||||||
|
};
|
||||||
191
app/lib/team_folder.js
Normal file
191
app/lib/team_folder.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小组作品目录命名 —— **单一 SoT**
|
||||||
|
*
|
||||||
|
* 【业务来源】R190 管理人员反馈:
|
||||||
|
* 「参赛小组上传作品时由于他们的作品分为两个文件且会自行命名,所以进行评分时
|
||||||
|
* 两个文件分开下载经常出现混乱,现在提出了想法,当参赛小组上传作品时,后台
|
||||||
|
* 自动生成一个以他们小组编号命名的文件夹,将他们的作品存放在这个文件夹中。」
|
||||||
|
*
|
||||||
|
* 【本模块的唯一职责】把「一个小组」映射成一个**安全、可读、稳定**的目录名。
|
||||||
|
* · 上传落盘(service/upload.js)与管理端打包(service/admin.js)**必须**都调这里
|
||||||
|
* —— 否则「上传写进 A 目录、下载从 B 目录找」的错位会在上线后立刻炸。
|
||||||
|
* · 前端**不参与**命名,只展示后端回传的 folderName / teamNumber。
|
||||||
|
*
|
||||||
|
* 🔴 与既有口径的关系(⛔ 不得另立第二套):
|
||||||
|
* 小组编号的展示值沿用 `service/admin.js#resolveTeamLabels` 的
|
||||||
|
* `teams.team_number || teams.team_id`;本模块只负责把它**变成合法目录名**,
|
||||||
|
* 不重新定义「哪个字段算编号」。
|
||||||
|
*
|
||||||
|
* 🔴 为什么必须净化:目录名直接参与 `fs.mkdir` / `path.join`。
|
||||||
|
* `team_number` 来自报名表导入,历史上出现过带 `/` `\` 的值会被当成路径分隔符
|
||||||
|
* ⇒ 文件落到意料之外的目录(甚至穿越到 works 之外)。故:
|
||||||
|
* · 替换 Windows 非法字符 `\ / : * ? " < > |` 与控制字符
|
||||||
|
* · 去掉首尾空白与首尾点(Windows 不允许尾点,且 `..` 是穿越向量)
|
||||||
|
* · 限长 60 字符(避免超过单段 255 字节上限,也给中文留余量)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 单段目录名/文件名主体的最大长度(字符数,非字节) */
|
||||||
|
const MAX_SEGMENT = 60;
|
||||||
|
|
||||||
|
/** 非法字符:Windows 保留字符 + 控制字符 */
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const ILLEGAL_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
|
||||||
|
|
||||||
|
/** Windows 设备保留名(不区分大小写,含扩展名前的部分) */
|
||||||
|
const RESERVED_NAMES = new Set([
|
||||||
|
'con', 'prn', 'aux', 'nul',
|
||||||
|
'com0', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
|
||||||
|
'lpt0', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 净化单个路径段(目录名或文件名主体)。
|
||||||
|
*
|
||||||
|
* @param {*} raw - 原始值(任意类型,null/undefined 视为空)
|
||||||
|
* @param {string} [fallback] - 净化后为空时的兜底值
|
||||||
|
* @returns {string} 安全的单段名称
|
||||||
|
*/
|
||||||
|
function sanitizeSegment(raw, fallback = '未命名') {
|
||||||
|
let s = String(raw === null || raw === undefined ? '' : raw);
|
||||||
|
s = s.replace(ILLEGAL_CHARS, '_');
|
||||||
|
// 折叠空白(含全角空格转半角后折叠),去掉首尾空白
|
||||||
|
s = s.replace(/\u3000/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
// 去掉首部点(`.` / `..` / `...foo` 都会被误判)与尾部点
|
||||||
|
s = s.replace(/^\.+/, '').replace(/\.+$/, '').trim();
|
||||||
|
if (s.length > MAX_SEGMENT) s = s.slice(0, MAX_SEGMENT).trim();
|
||||||
|
if (!s) return fallback;
|
||||||
|
// 保留名兜底:`CON` → `CON_`
|
||||||
|
if (RESERVED_NAMES.has(s.toLowerCase())) return s + '_';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 净化一个**文件名**(保留扩展名,主体走 sanitizeSegment)。
|
||||||
|
*
|
||||||
|
* @param {*} raw - 原始文件名(可能带路径)
|
||||||
|
* @param {string} [fallbackStem] - 主体为空时的兜底
|
||||||
|
* @param {string} [fallbackExt] - 无扩展名时的兜底扩展名(含点,如 '.pdf')
|
||||||
|
* @returns {string} 安全文件名
|
||||||
|
*/
|
||||||
|
function sanitizeFileName(raw, fallbackStem = '作品', fallbackExt = '') {
|
||||||
|
const original = String(raw === null || raw === undefined ? '' : raw);
|
||||||
|
// 只取 basename:同时切开 `/` 与 `\`(path.basename 在 Linux 上不切 `\`)
|
||||||
|
const base = original.replace(/^.*[\\/]/, '');
|
||||||
|
const dot = base.lastIndexOf('.');
|
||||||
|
const hasExt = dot > 0 && dot < base.length - 1;
|
||||||
|
const rawStem = hasExt ? base.slice(0, dot) : base;
|
||||||
|
const rawExt = hasExt ? base.slice(dot + 1) : '';
|
||||||
|
|
||||||
|
// 扩展名只允许字母数字,最长 10 位;出现异常字符即视为无扩展名
|
||||||
|
const ext = /^[A-Za-z0-9]{1,10}$/.test(rawExt) ? '.' + rawExt.toLowerCase() : fallbackExt;
|
||||||
|
const stem = sanitizeSegment(rawStem, fallbackStem);
|
||||||
|
return stem + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析「小组作品目录名」—— 上传落盘与管理端打包共用的唯一入口。
|
||||||
|
*
|
||||||
|
* 三态优先级(与 `resolveTeamLabels` 的 teamNumber 口径对齐):
|
||||||
|
* ① `teamNumber`(= `teams.team_number || teams.team_id`,人类可读编号,如 BRICS2026-ST-027-94001)
|
||||||
|
* ② 无编号但有队伍归属 ⇒ `未编组_<teamUid>`(同队成员仍归到同一目录)
|
||||||
|
* ③ 连队伍归属都没有(单人参赛 / 历史脏数据)⇒ `未编组_<aggregationUid>`(每人独立,防撞车)
|
||||||
|
*
|
||||||
|
* @param {Object} p
|
||||||
|
* @param {?string} p.teamNumber - 人类可读小组编号
|
||||||
|
* @param {?number} p.teamUid - 队伍号(组内唯一)
|
||||||
|
* @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底)
|
||||||
|
* @returns {string} 安全的目录名(保证非空、无路径分隔符)
|
||||||
|
*/
|
||||||
|
function resolveTeamFolderName({ teamNumber, teamUid, aggregationUid } = {}) {
|
||||||
|
const label = teamNumber === null || teamNumber === undefined ? '' : String(teamNumber).trim();
|
||||||
|
if (label) return sanitizeSegment(label, '未编组');
|
||||||
|
|
||||||
|
if (teamUid !== null && teamUid !== undefined && String(teamUid).trim() !== '') {
|
||||||
|
return sanitizeSegment(`未编组_${teamUid}`, '未编组');
|
||||||
|
}
|
||||||
|
if (aggregationUid !== null && aggregationUid !== undefined && String(aggregationUid).trim() !== '') {
|
||||||
|
return sanitizeSegment(`未编组_${aggregationUid}`, '未编组');
|
||||||
|
}
|
||||||
|
return '未编组';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接「小组作品目录」的相对路径(相对 `app/public`,正斜杠)。
|
||||||
|
*
|
||||||
|
* @param {string} group - 组别
|
||||||
|
* @param {string} folderName - resolveTeamFolderName 的返回值
|
||||||
|
* @returns {string} 如 `works/benke/BRICS2026-ST-027-94001`
|
||||||
|
*/
|
||||||
|
function teamFolderRelative(group, folderName) {
|
||||||
|
return ['works', sanitizeSegment(group, 'unknown'), sanitizeSegment(folderName, '未编组')]
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件名主体(不含扩展名)的字符上限。
|
||||||
|
*
|
||||||
|
* 🔴 为什么要有上限:下载名 = 小组编号(≤60) + 姓名(≤60) + 原文件名(≤120),
|
||||||
|
* 三者直接拼接可到 240 字符。Windows 资源管理器对单文件名上限 255 **字符**
|
||||||
|
* (NTFS 255 UTF-16 单元),240 已贴边,再加上浏览器自动加的 `(1)` 后缀就会失败。
|
||||||
|
* 故统一收敛到 150,保证「编号 + 姓名 + 关键文件名」都能看见,又不触系统上限。
|
||||||
|
*/
|
||||||
|
const MAX_DOWNLOAD_NAME = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接「管理端下载文件名」—— 中职/高职大题(Word)单个下载与批量 ZIP 包内名共用。
|
||||||
|
*
|
||||||
|
* 格式:`<小组编号>-<姓名>-<原文件名>`(R195 需求:小组编号放最前)
|
||||||
|
* 例:`BRICS2026-ST-027-94001-张三-大题作答.docx`
|
||||||
|
*
|
||||||
|
* 🔴 单一口径:小组编号三态(teamNumber → `未编组_<teamUid>` → `未编组_<aggUid>`)
|
||||||
|
* 完全复用 resolveTeamFolderName,与本组上传落盘目录名**必然一致**
|
||||||
|
* ⇒ 管理员把下载文件解压回工作目录时,文件名前缀可直接对上作品目录。
|
||||||
|
* ⛔ 不得在 controller / service 各拼一套(尤其是「有队伍但 teams 表缺元信息」这一档,
|
||||||
|
* 两处拼法分叉后会出现「同一个人下载出两个不一样的前缀」)。
|
||||||
|
*
|
||||||
|
* @param {Object} p
|
||||||
|
* @param {?string} p.teamNumber - teams.team_number || teams.team_id
|
||||||
|
* @param {?number} p.teamUid - 队伍号(组内唯一)
|
||||||
|
* @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底,防多人同名前缀重号)
|
||||||
|
* @param {?string} p.personName - 选手姓名(缺省则跳过该段)
|
||||||
|
* @param {?string} p.originalName - 原文件名(选手上传名)
|
||||||
|
* @param {string} [p.fallbackStem] - 原文件名为空时的兜底主体
|
||||||
|
* @returns {string} 安全文件名(非空、无路径分隔符、长度受限)
|
||||||
|
*/
|
||||||
|
function buildTeamDownloadName({
|
||||||
|
teamNumber,
|
||||||
|
teamUid,
|
||||||
|
aggregationUid,
|
||||||
|
personName,
|
||||||
|
originalName,
|
||||||
|
fallbackStem = '大题作答',
|
||||||
|
} = {}) {
|
||||||
|
const prefix = resolveTeamFolderName({ teamNumber, teamUid, aggregationUid });
|
||||||
|
const person = personName === null || personName === undefined
|
||||||
|
? ''
|
||||||
|
: sanitizeSegment(personName, '');
|
||||||
|
const base = sanitizeFileName(originalName, fallbackStem);
|
||||||
|
|
||||||
|
// 拆扩展名:截断只能砍主体,⛔ 不能把 `.docx` 砍掉(砍掉后浏览器会存成未知类型)
|
||||||
|
const dot = base.lastIndexOf('.');
|
||||||
|
const stem = dot > 0 ? base.slice(0, dot) : base;
|
||||||
|
const ext = dot > 0 ? base.slice(dot) : '';
|
||||||
|
|
||||||
|
const head = [prefix, person].filter(Boolean).join('-');
|
||||||
|
// 预留 1 个字符给分隔符 '-'
|
||||||
|
const room = MAX_DOWNLOAD_NAME - head.length - ext.length - 1;
|
||||||
|
const finalStem = stem.length > room ? stem.slice(0, Math.max(1, room)).trim() : stem;
|
||||||
|
return `${head}-${finalStem}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MAX_SEGMENT,
|
||||||
|
MAX_DOWNLOAD_NAME,
|
||||||
|
sanitizeSegment,
|
||||||
|
sanitizeFileName,
|
||||||
|
resolveTeamFolderName,
|
||||||
|
teamFolderRelative,
|
||||||
|
buildTeamDownloadName,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 继续执行后续中间件和控制器
|
// 继续执行后续中间件和控制器
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
72
app/model/adminAuditLog.js
Normal file
72
app/model/adminAuditLog.js
Normal 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;
|
||||||
|
};
|
||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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'],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,13 +151,34 @@ 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);
|
||||||
|
// 成绩排名表导出(R220):中职/高职按小组**客观题分**降序**全量**导出,整行按客观题分区间上底色。
|
||||||
|
// ⚠️ 第三个产物,⛔ 不可与上面两个互换:export-excel = 内部核查表(含身份证号/手机号)、
|
||||||
|
// export-publicity = 对外公示表(模板半成品、获奖等级留空)、
|
||||||
|
// 本接口 = 排名速览表(一小组一行、含区间底色与区间统计、无任何敏感字段)。
|
||||||
|
router.get('/api/admin/export-score-rank/:group', adminAuth, controller.admin.exportScoreRank);
|
||||||
|
// 赛项信息(R176):成绩公示表标题的「赛项名称 / 赛项编号」,管理后台可维护。
|
||||||
|
// ⚠️ 两个值只影响公示表标题文案,与成绩/账号/队伍无关。
|
||||||
|
router.get('/api/admin/competition-item', adminAuth, controller.admin.competitionItemInfo);
|
||||||
|
router.put('/api/admin/competition-item', adminAuth, controller.admin.updateCompetitionItemInfo);
|
||||||
router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload);
|
router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload);
|
||||||
|
// 单小组作品压缩包(R190):zip 本身即小组目录,包内平铺该组文件。
|
||||||
|
// ⚠️ 必须注册在 `/api/admin/works/:group` 之前不是硬要求(路径段数不同,不冲突),
|
||||||
|
// 但保持「同一业务相邻登记」,避免后来者以为是两条无关通道。
|
||||||
|
router.get('/api/admin/team-zip/:group', adminAuth, controller.admin.teamZip);
|
||||||
router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
|
router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
|
||||||
|
|
||||||
// 时间设置
|
// 时间设置
|
||||||
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 +209,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 +242,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);
|
||||||
|
|||||||
1361
app/service/admin.js
1361
app/service/admin.js
File diff suppressed because it is too large
Load Diff
270
app/service/adminUser.js
Normal file
270
app/service/adminUser.js
Normal 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;
|
||||||
@@ -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}` }] });
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ const Service = require('egg').Service;
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const {
|
||||||
|
sanitizeSegment,
|
||||||
|
sanitizeFileName,
|
||||||
|
resolveTeamFolderName,
|
||||||
|
} = require('../lib/team_folder');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传服务 - UploadService
|
* 上传服务 - UploadService
|
||||||
@@ -73,35 +78,359 @@ class UploadService extends Service {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 小组(队伍)上下文 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存作品文件
|
* 解析当前提交者所属「小组」的上下文 —— 上传落盘与管理端打包共用的唯一解析入口。
|
||||||
|
*
|
||||||
|
* 🔴 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)。
|
||||||
|
* teamUid 解析优先级与 `service/admin.js#resolveTeamLabels` 完全同源:
|
||||||
|
* `aggregation.team_uid`(报名表聚合,权威)→ `contestants.team_id`(兜底)
|
||||||
|
*
|
||||||
|
* @param {Object} aggregation - getUserAggregation() 的返回值(含 include team)
|
||||||
|
* @param {string} group - 组别
|
||||||
|
* @returns {Promise<{teamUid:?number, teamNumber:?string, folderName:string, contestant:?Object}>}
|
||||||
|
*/
|
||||||
|
async resolveTeamContext(aggregation, group) {
|
||||||
|
const { ctx } = this;
|
||||||
|
if (!aggregation) {
|
||||||
|
return { teamUid: null, teamNumber: null, folderName: resolveTeamFolderName({}), contestant: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选手档案:既用于 team_id 兜底,也用于取姓名做提示文案
|
||||||
|
const contestant = await ctx.model.Contestant.findByPk(aggregation.account_uid, {
|
||||||
|
attributes: ['id', 'name', 'group', 'team_id'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let teamUid = aggregation.team_uid;
|
||||||
|
if (teamUid === null || teamUid === undefined) {
|
||||||
|
teamUid = (contestant && contestant.team_id !== null && contestant.team_id !== undefined)
|
||||||
|
? contestant.team_id
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 队伍元信息:优先用 include 进来的 team;缺失时按 (group, teamUid) 回查,
|
||||||
|
// 避免「有归属无元信息」时把编号退化成兜底名(那会让同队两人的目录名不一致)。
|
||||||
|
let team = aggregation.team || null;
|
||||||
|
if (!team && teamUid !== null && teamUid !== undefined) {
|
||||||
|
team = await ctx.model.Team.findOne({
|
||||||
|
where: { team_uid: teamUid, group_name: group },
|
||||||
|
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamNumber = team ? (team.team_number || team.team_id || null) : null;
|
||||||
|
const folderName = resolveTeamFolderName({
|
||||||
|
teamNumber,
|
||||||
|
teamUid,
|
||||||
|
aggregationUid: aggregation.aggregation_uid,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { teamUid, teamNumber, folderName, contestant };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询「组内上传状态」—— 本科/教师组的组内互斥判定唯一事实来源。
|
||||||
|
*
|
||||||
|
* 【业务规则(R190)】
|
||||||
|
* ① 同一参赛小组内,只要有任一成员上传过作品,其他成员就不能再上传;
|
||||||
|
* ② 「替换权」只归**组内最早提交者**(归属人),他在截止前可以随时替换;
|
||||||
|
* ③ 无队伍归属的独立参赛者不参与组内互斥(自己传自己的)。
|
||||||
|
*
|
||||||
|
* ⚠️ 判据必须按 (group, teamUid) 取**全部成员**再数提交记录 —— 旧实现
|
||||||
|
* `hasSubmitted()` 只数本人的 `aggregation_uid`,同组他人上传后自己仍可继续传,
|
||||||
|
* 正是本次要修的缺陷。
|
||||||
|
*
|
||||||
|
* @param {Object} aggregation - getUserAggregation() 的返回值
|
||||||
|
* @param {string} group - 组别
|
||||||
|
* @returns {Promise<Object>} 见下方 result 结构
|
||||||
|
*/
|
||||||
|
async getTeamUploadState(aggregation, group) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const Op = ctx.model.Sequelize.Op;
|
||||||
|
const SubmissionModel = group === 'benke'
|
||||||
|
? ctx.model.BenkeSubmission
|
||||||
|
: ctx.model.JiaoshiSubmission;
|
||||||
|
|
||||||
|
const teamCtx = await this.resolveTeamContext(aggregation, group);
|
||||||
|
const selfAggUid = aggregation.aggregation_uid;
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
teamUid: teamCtx.teamUid,
|
||||||
|
teamNumber: teamCtx.teamNumber,
|
||||||
|
folderName: teamCtx.folderName,
|
||||||
|
memberCount: 1,
|
||||||
|
hasAnySubmission: false,
|
||||||
|
owner: null, // { aggregationUid, contestantId, name }
|
||||||
|
isOwner: false,
|
||||||
|
locked: false, // true = 组内别人已上传,本人不能再传
|
||||||
|
lockMessage: null,
|
||||||
|
teamFiles: [], // [{ fileName, fileType, uploaderName, isMine }]
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 无队伍归属:独立参赛,组内锁不成立 ----
|
||||||
|
if (teamCtx.teamUid === null || teamCtx.teamUid === undefined) {
|
||||||
|
const mine = await this.hasSubmitted(selfAggUid, group);
|
||||||
|
result.hasAnySubmission = mine;
|
||||||
|
result.isOwner = mine;
|
||||||
|
if (mine) {
|
||||||
|
result.owner = {
|
||||||
|
aggregationUid: selfAggUid,
|
||||||
|
contestantId: aggregation.account_uid,
|
||||||
|
name: (teamCtx.contestant && teamCtx.contestant.name) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 取同组同队的全部成员 ----
|
||||||
|
// 🔴 必须先按组别过滤再认 teamUid:aggregation.team_uid 是全局序列的弱引用,
|
||||||
|
// 不加组别限定会把别的组里同号队伍的人算进来(跨组串组,历史上已踩过)。
|
||||||
|
const aggRows = await ctx.model.Aggregation.findAll({
|
||||||
|
where: { team_uid: teamCtx.teamUid },
|
||||||
|
attributes: ['aggregation_uid', 'account_uid'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
const candIds = aggRows.map(a => a.account_uid).filter(v => v !== null && v !== undefined);
|
||||||
|
const memberContestants = candIds.length
|
||||||
|
? await ctx.model.Contestant.findAll({
|
||||||
|
where: { id: { [Op.in]: candIds }, group },
|
||||||
|
attributes: ['id', 'name'],
|
||||||
|
raw: true,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const nameOf = new Map(memberContestants.map(c => [String(c.id), c.name]));
|
||||||
|
const groupMemberIds = new Set(memberContestants.map(c => String(c.id)));
|
||||||
|
const members = aggRows.filter(a => groupMemberIds.has(String(a.account_uid)));
|
||||||
|
result.memberCount = Math.max(members.length, 1);
|
||||||
|
|
||||||
|
// ---- 成员提交记录:按 aggregation_uid 取最早一条,再全局取最早者作归属人 ----
|
||||||
|
const memberAggUids = members.map(m => m.aggregation_uid);
|
||||||
|
let firstByAgg = new Map();
|
||||||
|
if (memberAggUids.length > 0) {
|
||||||
|
const subs = await SubmissionModel.findAll({
|
||||||
|
where: { aggregation_uid: { [Op.in]: memberAggUids } },
|
||||||
|
attributes: ['id', 'aggregation_uid', 'upload_time', 'file_name', 'file_type'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
for (const s of subs) {
|
||||||
|
const t = s.upload_time ? new Date(s.upload_time).getTime() : 0;
|
||||||
|
const cur = firstByAgg.get(String(s.aggregation_uid));
|
||||||
|
// 同一 aggregation 有多行(word/ppt)时取最早;同时刻用更小 id 保序,保证可复现
|
||||||
|
if (!cur || t < cur.t || (t === cur.t && s.id < cur.id)) {
|
||||||
|
firstByAgg.set(String(s.aggregation_uid), { t, id: s.id, aggUid: s.aggregation_uid });
|
||||||
|
}
|
||||||
|
// 组内已上传文件清单:让被锁成员**看得见**「组里已经有什么」,
|
||||||
|
// 而不是只收到一句「你不能传」。
|
||||||
|
result.teamFiles.push({
|
||||||
|
fileName: s.file_name || '',
|
||||||
|
fileType: s.file_type || 'word',
|
||||||
|
uploaderName: nameOf.get(String(
|
||||||
|
(members.find(m => String(m.aggregation_uid) === String(s.aggregation_uid)) || {}).account_uid
|
||||||
|
)) || null,
|
||||||
|
isMine: String(s.aggregation_uid) === String(selfAggUid),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 稳定排序:word 在前、ppt 在后,便于前端固定展示顺序
|
||||||
|
result.teamFiles.sort((a, b) => String(a.fileType).localeCompare(String(b.fileType)));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.hasAnySubmission = firstByAgg.size > 0;
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
for (const v of firstByAgg.values()) {
|
||||||
|
if (!best || v.t < best.t || (v.t === best.t && v.id < best.id)) best = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best) {
|
||||||
|
const ownerMember = members.find(m => String(m.aggregation_uid) === String(best.aggUid));
|
||||||
|
const ownerContestantId = ownerMember ? ownerMember.account_uid : null;
|
||||||
|
result.owner = {
|
||||||
|
aggregationUid: best.aggUid,
|
||||||
|
contestantId: ownerContestantId,
|
||||||
|
name: ownerContestantId !== null ? (nameOf.get(String(ownerContestantId)) || null) : null,
|
||||||
|
};
|
||||||
|
result.isOwner = String(best.aggUid) === String(selfAggUid);
|
||||||
|
result.locked = !result.isOwner;
|
||||||
|
if (result.locked) {
|
||||||
|
const who = result.owner.name || '同组其他成员';
|
||||||
|
result.lockMessage =
|
||||||
|
`您的小组已由「${who}」上传作品。为保持组内作品唯一,`
|
||||||
|
+ `只有「${who}」可在截止时间前替换文件,其他成员不能再上传。`
|
||||||
|
+ `如需调整,请联系「${who}」处理。`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组内互斥守卫 —— 上传/合并两条路径**都必须**先过这里。
|
||||||
|
*
|
||||||
|
* @param {Object} aggregation
|
||||||
|
* @param {string} group
|
||||||
|
* @returns {Promise<{ok:boolean, message:string, state:Object}>}
|
||||||
|
*/
|
||||||
|
async assertTeamUploadAllowed(aggregation, group) {
|
||||||
|
const state = await this.getTeamUploadState(aggregation, group);
|
||||||
|
if (state.locked) {
|
||||||
|
return { ok: false, message: state.lockMessage, state };
|
||||||
|
}
|
||||||
|
return { ok: true, message: '', state };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 作品落盘 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保小组作品目录存在并返回绝对路径。
|
||||||
|
*
|
||||||
|
* @param {string} group - 组别
|
||||||
|
* @param {string} folderName - 小组目录名
|
||||||
|
* @returns {string} 目录绝对路径
|
||||||
|
*/
|
||||||
|
ensureWorkDir(group, folderName) {
|
||||||
|
const folder = sanitizeSegment(folderName, '未编组');
|
||||||
|
const dir = path.join(this.app.baseDir, 'app/public/works', group, folder);
|
||||||
|
try {
|
||||||
|
// 无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
} catch (mkErr) {
|
||||||
|
this.app.logger.error('[ensureWorkDir] mkdir failed', { dir, message: mkErr.message });
|
||||||
|
throw mkErr;
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算作品落盘目标(目录 + 最终文件名)—— 上传与分片合并共用的**唯一**命名实现。
|
||||||
|
*
|
||||||
|
* 🔴 为什么必须收敛:改造前 `service/upload.js#saveWorkFile` 与
|
||||||
|
* `controller/upload.js#mergeChunks` 各写了一份 `work_<ts>_<rand><ext>` 随机命名,
|
||||||
|
* 两处一旦漂移就会出现「合并落 A、下载找 B」。现统一到本方法。
|
||||||
|
*
|
||||||
|
* 落点:`app/public/works/<group>/<小组编号>/<原始文件名>`
|
||||||
|
*
|
||||||
|
* @param {Object} p
|
||||||
|
* @param {string} p.group - 组别
|
||||||
|
* @param {string} p.folderName - 小组目录名(resolveTeamFolderName)
|
||||||
|
* @param {string} p.filename - 上传时的原始文件名
|
||||||
|
* @param {string} [p.fileType] - word / ppt
|
||||||
|
* @param {?number} [p.aggregationUid] - 提交者聚合 ID(同名文件归属判定用)
|
||||||
|
* @returns {Promise<{dir:string, folder:string, fileName:string, absPath:string, relPath:string}>}
|
||||||
|
*/
|
||||||
|
async prepareWorkTarget({ group, folderName, filename, fileType = 'word', aggregationUid = null }) {
|
||||||
|
const dir = this.ensureWorkDir(group, folderName);
|
||||||
|
const folder = sanitizeSegment(folderName, '未编组');
|
||||||
|
|
||||||
|
const fileName = await this._resolveTargetFileName({
|
||||||
|
dir, group, folder, filename, fileType, aggregationUid,
|
||||||
|
});
|
||||||
|
const relPath = ['works', group, folder, fileName].join('/');
|
||||||
|
|
||||||
|
return { dir, folder, fileName, absPath: path.join(dir, fileName), relPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 决定小组目录内的最终文件名。
|
||||||
|
*
|
||||||
|
* 【规则】保留上传时的原始文件名(用户裁定);仅在**确实会覆盖他人文件**时才加序号。
|
||||||
|
* ① 目标不存在 ⇒ 直接用原名
|
||||||
|
* ② 目标存在,且 DB 里同路径记录属于**同一聚合、同一 file_type** ⇒ 是同一次提交的新版本
|
||||||
|
* ⇒ 用原名(走原子 rename 覆盖,`saveSubmissionRecord` 会清理旧路径)
|
||||||
|
* ③ 目标存在但查不到记录 / 属于别人 / 属不同 file_type ⇒ 加 `(2)(3)…` 序号,绝不覆盖
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _resolveTargetFileName({ dir, group, folder, filename, fileType, aggregationUid }) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const baseName = sanitizeFileName(filename);
|
||||||
|
const abs = path.join(dir, baseName);
|
||||||
|
if (!fs.existsSync(abs)) return baseName;
|
||||||
|
|
||||||
|
const rel = ['works', group, folder, baseName].join('/');
|
||||||
|
const SubmissionModel = group === 'benke'
|
||||||
|
? ctx.model.BenkeSubmission
|
||||||
|
: ctx.model.JiaoshiSubmission;
|
||||||
|
const row = await SubmissionModel.findOne({
|
||||||
|
where: { file_path: rel },
|
||||||
|
attributes: ['id', 'aggregation_uid', 'file_type'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sameLogicalFile = row
|
||||||
|
&& String(row.file_type || 'word') === String(fileType || 'word')
|
||||||
|
&& (aggregationUid === null || String(row.aggregation_uid) === String(aggregationUid));
|
||||||
|
if (sameLogicalFile) return baseName;
|
||||||
|
|
||||||
|
// 撞名且不属同一逻辑文件 ⇒ 加序号,保护已在使用的文件
|
||||||
|
const dot = baseName.lastIndexOf('.');
|
||||||
|
const stem = dot > 0 ? baseName.slice(0, dot) : baseName;
|
||||||
|
const ext = dot > 0 ? baseName.slice(dot) : '';
|
||||||
|
for (let i = 2; i <= 99; i += 1) {
|
||||||
|
const candidate = `${stem}(${i})${ext}`;
|
||||||
|
if (!fs.existsSync(path.join(dir, candidate))) return candidate;
|
||||||
|
}
|
||||||
|
// 极端兜底:用时间戳确保唯一(不抛错,宁可名字丑也不丢文件)
|
||||||
|
return `${stem}_${Date.now()}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把临时文件原子地落到最终位置,并补齐 size / md5。
|
||||||
|
*
|
||||||
|
* 🔴 为什么要先写临时名再 rename:直接写最终文件时,若恰好有管理员在下载,
|
||||||
|
* 会读到**半截文件**(zip 里就是损坏的 PDF)。同目录 rename 在同一文件系统内
|
||||||
|
* 是原子操作,读方要么看到旧的完整文件、要么看到新的完整文件。
|
||||||
|
*
|
||||||
|
* @param {string} tmpPath - 临时文件绝对路径(必须与目标同目录)
|
||||||
|
* @param {Object} target - prepareWorkTarget() 的返回值
|
||||||
|
* @returns {Promise<{filePath:string, fileName:string, fileSize:number, md5:string}>}
|
||||||
|
*/
|
||||||
|
async commitWorkFile(tmpPath, target) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmpPath, target.absPath);
|
||||||
|
} catch (renameErr) {
|
||||||
|
this.ctx.helper.safeCleanup(tmpPath, { label: 'commitWorkFile-tmp' });
|
||||||
|
throw renameErr;
|
||||||
|
}
|
||||||
|
const stat = fs.statSync(target.absPath);
|
||||||
|
const md5 = await this._calculateMd5(target.absPath);
|
||||||
|
return {
|
||||||
|
filePath: target.relPath,
|
||||||
|
fileName: target.fileName,
|
||||||
|
fileSize: stat.size,
|
||||||
|
md5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存作品文件(单次流式上传)
|
||||||
|
*
|
||||||
* @param {ReadableStream} stream - 文件流
|
* @param {ReadableStream} stream - 文件流
|
||||||
* @param {string} filename - 原始文件名
|
* @param {string} filename - 原始文件名
|
||||||
* @param {string} group - 组别
|
* @param {string} group - 组别
|
||||||
|
* @param {string} folderName - 小组目录名(resolveTeamFolderName 的返回值)
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {string} [opts.fileType] - word / ppt
|
||||||
|
* @param {?number} [opts.aggregationUid] - 提交者聚合 ID
|
||||||
* @returns {Promise<Object>} { filePath, fileName, fileSize, md5 }
|
* @returns {Promise<Object>} { filePath, fileName, fileSize, md5 }
|
||||||
*/
|
*/
|
||||||
async saveWorkFile(stream, filename, group) {
|
async saveWorkFile(stream, filename, group, folderName, opts = {}) {
|
||||||
const { app } = this;
|
const { fileType = 'word', aggregationUid = null } = opts;
|
||||||
|
const target = await this.prepareWorkTarget({
|
||||||
|
group, folderName, filename, fileType, aggregationUid,
|
||||||
|
});
|
||||||
|
|
||||||
// 确保上传目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT)
|
// 临时名:以 `.` 开头 + 固定前缀,便于残留扫描识别;同目录保证 rename 原子
|
||||||
const uploadDir = path.join(app.baseDir, 'app/public/works', group);
|
const tmpName = `.upload_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
|
||||||
try {
|
+ path.extname(target.fileName);
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
const tmpPath = path.join(target.dir, tmpName);
|
||||||
} catch (mkErr) {
|
|
||||||
this.app.logger.error('[saveWorkFile] mkdir failed', { uploadDir, message: mkErr.message });
|
|
||||||
throw mkErr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建安全文件名
|
const writeStream = fs.createWriteStream(tmpPath);
|
||||||
const ext = path.extname(filename);
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`;
|
|
||||||
const filePath = path.join(uploadDir, safeFileName);
|
|
||||||
|
|
||||||
// 将文件流写入磁盘(用 pipe 自动处理背压,避免大文件内存暴涨 / 写入被截断)
|
|
||||||
const writeStream = fs.createWriteStream(filePath);
|
|
||||||
let fileSize = 0;
|
let fileSize = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
stream.pipe(writeStream);
|
stream.pipe(writeStream);
|
||||||
stream.on('data', chunk => { fileSize += chunk.length; });
|
stream.on('data', chunk => { fileSize += chunk.length; });
|
||||||
@@ -113,28 +442,20 @@ class UploadService extends Service {
|
|||||||
// 🔴 落盘完整性校验(F17-B 防「假成功」):
|
// 🔴 落盘完整性校验(F17-B 防「假成功」):
|
||||||
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
|
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
|
||||||
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
|
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(tmpPath);
|
||||||
if (stat.size !== fileSize) {
|
if (stat.size !== fileSize) {
|
||||||
// 🔴 清理半截文件用 best-effort(同源缺陷族第三处):
|
// 🔴 清理半截文件用 best-effort:裸 unlinkSync 一旦失败会抛出「删除失败」,
|
||||||
// 裸 unlinkSync 一旦失败会抛出「删除失败」,把下面那条真正有诊断价值的
|
// 把下面那条真正有诊断价值的「文件写入不完整:期望 X 实际 Y」**顶掉**。
|
||||||
// 「文件写入不完整:期望 X 实际 Y」**顶掉**,排障时被引向错误方向。
|
this.ctx.helper.safeCleanup(tmpPath, { label: 'partialFile' });
|
||||||
// 清理成不成功都不影响结论——写入不完整这件事必须原样抛出。
|
|
||||||
this.ctx.helper.safeCleanup(filePath, { label: 'partialFile' });
|
|
||||||
throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`);
|
throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 任何失败都不留半截临时文件(best-effort,不掩盖真实错误)
|
||||||
|
this.ctx.helper.safeCleanup(tmpPath, { label: 'saveWorkFile-failed' });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
// 计算文件 MD5
|
return await this.commitWorkFile(tmpPath, target);
|
||||||
const md5 = await this._calculateMd5(filePath);
|
|
||||||
|
|
||||||
// 构建相对路径(相对于 app/public/)
|
|
||||||
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
|
|
||||||
|
|
||||||
return {
|
|
||||||
filePath: relativePath,
|
|
||||||
fileName: filename,
|
|
||||||
fileSize,
|
|
||||||
md5,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -291,9 +612,12 @@ 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');
|
||||||
|
// 🔴 R157:登记计数(⛔ 不抛错,保持 best-effort),使「旧文件清理失败」
|
||||||
|
// 在运行期可被读取,而不只沉在日志里。
|
||||||
|
this._noteDeleteResult(r, stalePath, 'overwrite-stale');
|
||||||
if (r !== 'removed') {
|
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 +698,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 +721,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 +735,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;
|
||||||
|
|||||||
@@ -379,7 +379,16 @@ module.exports = (appInfo) => {
|
|||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
config.multipart = {
|
config.multipart = {
|
||||||
mode: 'stream',
|
mode: 'stream',
|
||||||
fileSize: '100mb',
|
// 🔴 2026-10-23 R225:'100mb' -> '150mb'
|
||||||
|
// 缘由:教师组(jiaoshi)作品上传唯一通道 = POST /api/upload/:group 单次流式整包
|
||||||
|
// (分片通道前端已于 R43 删除),故全组共用这一个硬闸门。原值与前端
|
||||||
|
// uploadCombo.ZIP_MAX=200MB 长期不一致 ⇒ 100~200MB 的 zip 前端放行、后端拒收
|
||||||
|
// (MultipartFileTooLargeError),多位教师报障。
|
||||||
|
// ⚠️ 本值是**所有** multipart 通道的唯一上限,调整时须与下列三处同步:
|
||||||
|
// frontend/src/utils/uploadCombo.js (ZIP_MAX / OTHER_MAX)
|
||||||
|
// frontend/src/views/Competition.vue (同上常量 + 文案)
|
||||||
|
// deploy/competition_website.conf (client_max_body_size 200m,须 > 本值)
|
||||||
|
fileSize: '150mb',
|
||||||
whitelist: [
|
whitelist: [
|
||||||
// 文档类
|
// 文档类
|
||||||
'.pdf',
|
'.pdf',
|
||||||
@@ -499,6 +508,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 触发)
|
||||||
|
|||||||
@@ -4,43 +4,38 @@
|
|||||||
// 口令:下方内置 bcrypt 哈希仅为**开发/演示**默认值(对应公开弱口令,仅供本机初始化)。
|
// 口令:下方内置 bcrypt 哈希仅为**开发/演示**默认值(对应公开弱口令,仅供本机初始化)。
|
||||||
// 正式初始化(生产等)时应通过环境变量 ADMIN_INIT_PASSWORD 注入真实口令 ——
|
// 正式初始化(生产等)时应通过环境变量 ADMIN_INIT_PASSWORD 注入真实口令 ——
|
||||||
// 本文件⛔不落盘任何明文口令(铁律 25)。
|
// 本文件⛔不落盘任何明文口令(铁律 25)。
|
||||||
|
//
|
||||||
|
// ⚠️ 【R111 更正 · 实测】本 seeder 原先 `bulkInsert` 到 **`accounts`** 表(row 含
|
||||||
|
// role/name_cn/salt/id_number 等列);但登录链路读的是 **`admins`** 表
|
||||||
|
// (`service/auth.js:101 findAdmin()` → `model/admin.js:49 tableName='admins'`)
|
||||||
|
// ⇒ 原写法属**死写入**(种下的那行永远不会被登录读取)。
|
||||||
|
// 已改为写入 `admins`,并按该表真实列契约(`id/username/password/created_at/updated_at`,
|
||||||
|
// 见 `database/migrations/002-create-admins.js:17-46` 与 `database/schema.sql:20-29`)重写 row。
|
||||||
|
// 详见 `.workbuddy/memory/BACKLOG.md` 候选⑳。
|
||||||
module.exports = {
|
module.exports = {
|
||||||
up: async (queryInterface, Sequelize) => {
|
up: async (queryInterface, Sequelize) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
// `admins` 表列契约:id(自增主键,勿传)/ username / password / created_at / updated_at。
|
||||||
|
// ⛔ 该表**无** salt / role / name_cn / id_number / activated / score 等列。
|
||||||
const row = {
|
const row = {
|
||||||
role: 'admin',
|
username: 'admin',
|
||||||
name_cn: '系统管理员',
|
// bcrypt(cost=10) 哈希 = 开发默认弱口令(公开值,仅供本机初始化;生产须经 ADMIN_INIT_PASSWORD 覆盖)。
|
||||||
name_en: 'Administrator',
|
// 60 字符 < `admins.password VARCHAR(100)` ⇒ 列宽容得下。
|
||||||
gender: null,
|
|
||||||
phone: null,
|
|
||||||
email: 'admin@competition.com',
|
|
||||||
id_number: '000000000000000000',
|
|
||||||
password: '$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
|
password: '$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
|
||||||
salt: '$2b$10$Nc6PCJItIwuOzpM22dTiAe',
|
|
||||||
position: '系统管理员',
|
|
||||||
major: null,
|
|
||||||
country: '中国',
|
|
||||||
region: null,
|
|
||||||
organization_cn: '赛事组委会',
|
|
||||||
organization_en: 'Competition Committee',
|
|
||||||
activated: 1,
|
|
||||||
score: null,
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
// 若提供 ADMIN_INIT_PASSWORD,则以之为准重算 bcrypt 哈希(salt 取哈希前 29 字符的 bcrypt 盐前缀)。
|
// 若提供 ADMIN_INIT_PASSWORD,则以之为准重算 bcrypt 哈希(明文只经过程环境,⛔ 不落盘)。
|
||||||
const initPwd = process.env.ADMIN_INIT_PASSWORD;
|
const initPwd = process.env.ADMIN_INIT_PASSWORD;
|
||||||
if (initPwd) {
|
if (initPwd) {
|
||||||
const hash = require('bcrypt').hashSync(String(initPwd), 10);
|
row.password = require('bcrypt').hashSync(String(initPwd), 10);
|
||||||
row.password = hash;
|
|
||||||
row.salt = hash.slice(0, 29);
|
|
||||||
}
|
}
|
||||||
await queryInterface.bulkInsert('accounts', [row], {});
|
await queryInterface.bulkInsert('admins', [row], {});
|
||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
down: async (queryInterface, Sequelize) => {
|
||||||
await queryInterface.bulkDelete('accounts', {
|
await queryInterface.bulkDelete('admins', {
|
||||||
id_number: '000000000000000000',
|
username: 'admin',
|
||||||
}, {});
|
}, {});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
168
test/lib.test.js
Normal file
168
test/lib.test.js
Normal 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), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user