Files
competition-website/app/controller/admin.js

2848 lines
106 KiB
JavaScript
Raw Normal View History

'use strict';
const Controller = require('egg').Controller;
const path = require('path');
const fs = require('fs');
const { sanitizeUploadFileName } = require('../utils/filename');
// 🔴 文件 IO 并发护栏(与上传通道共享同一全局配额):限制单 worker 同时进行的文件流,
// 防止「无带宽限制 + 单 worker + 极端并发」下裸崩。生产下载经 X-Accel-Redirect 卸载给
// nginx(Node 不持有流,仅回头);开发回退流式时由本护栏按流生命周期释放配额。
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
// 🔴 同源 collapse(缺口9 修复):所有文件发送通道统一走 sendFile util
const { sendFile } = require('../utils/sendFile');
// R178 高危操作审计(best-effort,不阻断业务)
const { recordAudit, ACTIONS: AUDIT_ACTIONS } = require('../lib/auditLog');
security(config): 四个同族兜底缺陷一次收口 —— APP_KEYS / 弱口令 / IPv6 兜底 / 本地生产密钥同值(R106-D6~D9) 共同根因(R104 结论的延续):**同族问题各自实现必然漂移**。 本仓 DB_PASSWORD 与 JWT_SECRET(R105-D1)已统一为「缺凭据明确失败」, 但同族的另三处仍停在「可预测的公开兜底」上,另新增一处跨环境密钥同值。 D6 · APP_KEYS 派生兜底(原 config.default.js:157) 原:const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026'; config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS; 「派生」不等于安全:由 appInfo.name 可推算 ⇒ 等价于「Cookie 签名密钥公开 ⇒ 任何人可伪造已签名 Cookie」。收口到 _secret.cjs 唯一解析入口: · scripts/_secret.cjs 抽出通用 _loadSecret(),新增 loadAppKeys() (① env → ② scripts/.creds.local.json:app_keys → ③ 明确失败,⛔ 无兜底) · 生产安全守卫移除 config.keys === DEFAULT_APP_KEYS 比对分支(已无可比兜底值) · scripts/_creds.py 与 .creds.local.example.json 同步键面(两侧同源,永不漂移) D7 · 生产弱口令兜底(原 config.default.js:176) 原:config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456'; 而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD 一直是**注释状态** ⇒ 生产 实际取到的就是弱口令 '123456'。一旦走到「批量写默认口令」的路径(重置测试数据 / 显式 testFlag=true 的导入)会批量设弱口令 —— 属**静默弱口令**而非明确失败。 修法:生产(config.isProduction)未显式注入 ⇒ 置 null;使用点改走新增的 app/lib/passwordPolicy.requireTestPassword() 明确失败。测试环境语义逐字不变。 D8 · 本地 DB 兜底 host 引发 IPv6 建连超时(原 config.default.js:217) 原:host: process.env.DB_HOST || 'localhost'。Node >= 18 的 verbatim DNS 可能把 localhost 解析到 ::1,而本机 MariaDB 只听 IPv4 ⇒ **建连 ETIMEDOUT**(不报 1045、 不报 ECONNREFUSED),现象是「后端启动卡在 DB」,极易误判为「服务没起来」。 且本仓 scripts/_db.cjs:43 的兜底早已是 '127.0.0.1' —— 同子系统两套默认值必然漂移。 修法:兜底改为 IPv4 字面量(生产显式注入 DB_HOST ⇒ 逐字不变)。 配套(config.local.js 为 gitignored 本地文件,不入库):补 host/port 覆盖 —— 「第二道保险」原本只保账号/口令,反而漏掉了真正会出问题的连接目标。 D9 · 本地 dev 与生产共用同一 JWT/APP_KEYS(loadDeployEnv) 非生产跳过清单原本只含 DB_/REDIS_/ALIYUN_/SMS_,注释称「仍保留 JWT_SECRET / APP_KEYS …… 本地无副作用」—— 该结论不成立:本地进程持有 **生产签名密钥** ⇒ 本地 token 可冒充生产用户(反之亦然),两套环境的会话边界 实际被打通,属**安全属性**缺陷。 修法:新增 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过(⛔ 不用前缀以免误伤),本地改由 scripts/.creds.local.json 供值;Python 侧 scripts/_fixtures.py resolve_jwt_secret() 同步为「非生产优先本仓凭据文件」—— 否则脚本会用生产密钥签 token 交给本地后端 验签,必然 401(正是该函数注释里记录过的 23 项级联假红)。 行为等价性(生产逐字不变,已逐项实测): · D6/D8:生产显式注入 APP_KEYS / DB_HOST ⇒ 取值路径不变 · D7:生产 resetTestData 本有 403 守卫、导入默认 testFlag=false ⇒ 原不可达 · D9:isProdEnv=true ⇒ 不跳过任何键 ⇒ 生产加载路径不变 验证: · 配置层双环境断言:本地 jwt=8066e07d5683 / keys=e4cfe40ff3ea(≠ 生产 a05591750093 / ae0d155385c1);生产两值逐字不变;生产 testDefaultPassword=null · 本地后端真实重启(仅给 DB_PORT=3307、**不给 DB_HOST**)⇒ 启动成功, /api/health {"code":0,...,"db":"ok"};日志显示跳过 16 个键(含 JWT_SECRET/APP_KEYS) · D9 判决点 d9_verify_split.py:本地密钥签 token ⇒ 200;生产密钥 ⇒ **401** · 生产部署后 r106_prod_sentinel.js:pass=11 fail=0;/api/health 200 · 门禁:verify_no_secrets.py VERIFY_ALL_PASS(HIGH 0,判别力自证 17/17); verify_script_health.py H1–H4 PASS
2026-10-12 11:06:01 +08:00
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
const { requireTestPassword } = require('../lib/passwordPolicy');
/**
* 管理控制器 - AdminController
*
* 负责所有后台管理功能,包括数据统计、账号管理、Excel 导入、
* 提交记录查看、CSV 导出、批量下载、时间设置、评分、题目管理、
* 成绩发布、新闻管理、轮播图管理等。
*
* 所有接口均需管理员权限(middleware.auth({ adminOnly: true }))。
*/
class AdminController extends Controller {
/**
* 验证当前用户是否为管理员
* @returns {boolean}
*/
_ensureAdmin() {
const { ctx } = this;
const user = ctx.state.currentUser;
if (!user || user.role !== 'admin') {
ctx.body = ctx.helper.error(403, '无权限访问,需要管理员权限');
ctx.status = 403;
return false;
}
return true;
}
/**
* 验证组别参数是否有效
* @param {string} group - 组别
* @returns {boolean}
*/
_validateGroup(group, allGroups = false) {
const validGroupsExams = ['zhongzhi', 'gaozhi'];
const validGroupsAll = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
const validGroups = allGroups ? validGroupsAll : validGroupsExams;
if (!validGroups.includes(group)) {
this.ctx.body = this.ctx.helper.error(400, `无效的组别,仅支持 ${validGroups.join(' / ')}`);
return false;
}
return true;
}
/**
* 统一「大文件下载」出口(X-Accel-Redirect 根治方案)。
*
* 生产(config.xAccelRedirect.enabled=true):后端只回一个 X-Accel-Redirect 内部
* 重定向头,nginx 用 internal location 直接从磁盘流式吐文件 —— Node 不再持有文件流,
* 极端并发下几乎零占用 worker 的 fd / 事件循环,从根上消除雪崩。
*
* 开发(默认关闭):回退为 Node 直接流式,保持与改造前一致,便于本地调试。
*
* @param {object} ctx - Egg 上下文
* @param {object} app - Egg app(读 config.xAccelRedirect)
* @param {string} relativePath - 相对 app/public 的路径(如 works/benke/x.docx)
* @param {string} fileName - 原始文件名(用于 Content-Disposition)
* @param {string} contentType - MIME 类型
*/
async _sendFile(ctx, app, relativePath, fileName, contentType) {
// 🔴 同源 collapse(2026-08-12 缺口9 修复):所有文件发送统一走 sendFile util,
// 避免各通道自写 fs.createReadStream 漏 X-File-Size 哨兵导致前端完整性校验失效。
return sendFile(ctx, app, { relativePath, fileName, contentType });
}
// ==================== 数据统计 ====================
/**
* 数据总览统计
* GET /api/admin/stats
*
* 返回各组报名人数、激活数、提交数、提交率、总分等统计数据
*/
async stats() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
// 支持 ?excludeTest=1(或 ?scope=production)仅统计生产数据,排除测试数据
const excludeTest = ctx.query.excludeTest === '1' || ctx.query.excludeTest === 'true'
|| ctx.query.scope === 'production';
const stats = await service.admin.getStats({ excludeTest });
ctx.body = ctx.helper.success(stats, '获取统计数据成功');
} catch (err) {
ctx.logger.error('获取统计数据失败:', err);
ctx.body = ctx.helper.error(500, '获取统计数据失败');
}
}
// ==================== 账号管理 ====================
/**
* 分页获取账号列表
* GET /api/admin/accounts?group=xxx&keyword=xxx&page=1&pageSize=20
*
* 支持按组别和关键词筛选,关联团队信息
*/
async accounts() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
fix(admin): 修复选手管理「激活状态」「注册日期」筛选被静默丢弃 现象(人工测试反馈):选手管理页「激活状态」下拉选择后列表无变化。 根因 —— 两层都在丢参且零报错: - controller/admin.js 只解构 group/keyword/page/pageSize, 前端已发出的 activated 从未透传给 service - service/admin.js 即使收到也未解构 activated,where 中根本没有该条件 - 同族缺陷:前端还发了 startDate/endDate(注册日期范围), controller 同样未转发 => 该筛选同样失效(此前无人报告) 修复: - controller:将 activated/startDate/endDate 整体透传给 service - service:解构 activated,并对 BOOLEAN 列做白名单归一化 前端 el-select 传的是字符串 'true'/'false',直接入 where 会被 MySQL 隐式转为 0,导致「已激活」错查出「未激活」(比不筛选更难察觉); 白名单之外的值(含 undefined/'')一律视为未指定忽略, 避免畸形入参被当作 false 而静默缩窄结果集 验证(scripts/verify_accounts_filter.py,9/9 PASS,真实 HTTP + 独立 DB 真值): A1 无筛选 total=26 == DB 26 A2 activated=true -> 10 == DB 已激活数 A3 activated=false -> 16 A4 分区性 10 + 16 == 26(关键判别器) D1 日期限定 DB 实际存在那天 -> 20 == DB 当日 D2 负向对照(范围早于最早记录)-> 0 变异自证:拆掉 where.activated 分支重跑 -> A2/A2b/A3/A3b/A4 全红 EXIT=1, 失败信息精确复现原现象(total=26 != 10);还原后 diff 空、md5 一致、无标记残留 同族扫描:全后端 ctx.query 仅 11 处,逐点核对 => 仅 getAccounts 一对丢参 部署(生产 back-end): 线上 md5 与本地逐文件一致(controller ad0e60f8 / service 93eb3dbb) MainPID 3341710 -> 3343212;8023 监听正常;err 级日志 -- No entries -- 路由探针:health 200 / admin-login 错误口令 HTTP200+code4004 / 不存在路由 404 / 未授权 accounts 401
2026-10-08 11:22:18 +08:00
// ⚠️ 筛选条件必须**整体透传**给 service。此处曾只解构 group/keyword/page/pageSize,
// 导致前端已发出的 activated(激活状态)与 startDate/endDate(注册日期范围)
// 被静默丢弃 —— 选手管理页这两个筛选条件完全失效,且不报任何错
// (2026-10-08 人工测试反馈「激活状态搜索条件无效」)。
const { group, keyword, activated, startDate, endDate, page = 1, pageSize = 20 } = ctx.query;
const result = await service.admin.getAccounts({
group, keyword, activated, startDate, endDate, page, pageSize,
});
ctx.body = ctx.helper.success(result, '获取账号列表成功');
} catch (err) {
ctx.logger.error('获取账号列表失败:', err);
ctx.body = ctx.helper.error(500, '获取账号列表失败');
}
}
/**
* 获取「小组(团队)激活状态」清单(R84 续六新增)
* GET /api/admin/teams?group=xxx&activated=true|false&includeTest=0|1
*
* 需求:「选手管理」中查看已激活/未激活小组,**必须携带小组所属学校名称**。
* 口径(键 = 组别 + 队伍号;成员 = aggregation.team_uid ∪ contestants.team_id;
* 学校 = aggregation.organization_cn;激活 = 全组成员均已激活)详见
* `service/admin.js#getTeams` 的方法头注释 —— 那是唯一真相源。
*/
async teams() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
// ⚠️ 筛选条件必须**整体透传**(同 accounts():曾因只解构部分参数导致前端筛选静默失效)
const { group, activated, includeTest } = ctx.query;
const result = await service.admin.getTeams({ group, activated, includeTest });
ctx.body = ctx.helper.success(result, '获取小组列表成功');
} catch (err) {
ctx.logger.error('获取小组列表失败:', err);
ctx.body = ctx.helper.error(500, '获取小组列表失败');
}
}
/**
* 从 Excel 文件导入账号数据
* POST /api/admin/import-excel
*
* 上传 xlsx 文件,逐行解析并创建账号+团队+聚合记录
*/
async importExcel() {
const { ctx, service, app } = this;
if (!this._ensureAdmin()) return;
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
// 🔴 R161:临时文件路径必须在 try **外**声明,才能被 finally 看到。
// 原实现把 `unlinkSync` 写在 `await importFromExcel` 之后、外层 catch 之前
// ⇒ 解析/导入一抛错就跳过清理,永久泄漏 tmp/excel/import_*.xlsx(实测存量 41 个)。
let tempFilePath = null;
try {
// 获取上传的文件流
const stream = await ctx.getFileStream();
if (!stream) {
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 tempDir = path.join(app.baseDir, 'app/public/tmp/excel');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const tempFilePath = path.join(tempDir, `import_${Date.now()}${ext}`);
const writeStream = fs.createWriteStream(tempFilePath);
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
stream.on('error', reject);
});
// 调用服务解析 Excel 并导入
// testFlag 来自前端导入对话框的「标记为测试数据」勾选(multipart 非文件字段在 stream.fields)
const rawTestFlag = stream.fields && stream.fields.testFlag;
const testFlag = rawTestFlag === '1' || rawTestFlag === 'true' || rawTestFlag === true;
const result = await service.admin.importFromExcel(tempFilePath, { testFlag });
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
// 🔴 R161:清理已移至外层 finally(此处不再单独清理,避免重复与漏清)。
// 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败
const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : '';
feat(admin): 新增选手信息编辑接口 PUT /api/admin/contestants/:id(R108) 此前 contestants 表只有 Excel 导入一个写入口 —— 证件号/姓名/手机号录错后 改不了也删不掉。本提交新增受控编辑通道。 service.updateContestant(id, payload): · 白名单仅 name / phone / id_number;白名单外字段(group / 成绩类 / password / test_flag / team_id / activated)显式 400 拒绝,非静默忽略 (静默忽略会让管理员误以为改成功) · id_number 判据复用 lib/idCard.js(与导入通道同源):长度 != 18 拒绝; 校验位不符 / 含非法字符 => 放行 + warnings[](生产库现存正式考生中 即有 4 条校验位不符,硬拦截会造成合法数据改不动) · 唯一性冲突 => 409 · 改号连带重算口令:导入时 password = sha256(证号后 6 位),而证号即登录 账号 => 不同步重算则学生「账号对、口令错」(UI 上看不出来)。4 分支: 测试数据 / 已激活 / 已是 bcrypt => 不动 + 告警;未激活派生值 => 重算 · 同值提交(含仅大小写差异)不发 UPDATE · 审计日志证号脱敏、口令只记「是否重算」 controller.updateContestant:映射 400/404/409。 router:新增 PUT /api/admin/contestants/:id(adminAuth)。破坏面定级 = 非破坏性(白名单编辑 + 可逆 + 带审计)=> 未登记 DESTRUCTIVE_ENDPOINTS.json。 判据:scripts/verify_update_contestant.mjs(42 判据 + 4 变异自证)。 行号漂移纪律:controller/admin.js 被 3 个脚本按行号引用(:33/:1556/:1570) => 新增一律追加文件末尾,改后 3/3 未漂移。
2026-10-12 17:20:41 +08:00
// R107:18 位但校验位不符 / 含非法字符的行仍会导入,但需人工复核 —— 摘要里给出条数,
// 明细随 data.warnings 一并回传(前端在「导入结果」对话框展示)。
const warnTip = (result.warnings && result.warnings.length)
? `,${result.warnings.length} 行需人工复核` : '';
ctx.body = ctx.helper.success(
result,
`导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条${ignoredTip}${warnTip}`
);
} catch (err) {
ctx.logger.error('Excel 导入失败:', err);
const msg = (err && err.message) ? err.message : '未知错误';
// 注意:前端 onExcelImport 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀
ctx.body = ctx.helper.error(500, msg);
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
} finally {
// 🔴 R161:三态可观测(removed/missing/skipped),失败 logger.warn 不静默。
// 原 `catch (e) { /* ignore */ }` 完全静默 ⇒ 「已清理」不可观测。
if (tempFilePath) {
ctx.helper.safeCleanup(tempFilePath, { label: 'importContestants-tmp' });
}
}
}
/**
* 指导教师回填(R176)
* POST /api/admin/contestants/backfill-teacher
*
* multipart:file(报名表 xlsx)+ group(可选,限定组别)+ overwrite(可选,默认 1)
*
* 🔴 内存解析,**不落盘**:报名表含身份证号,而 `app/public` 对静态服务开放
* (题目导入曾落 `app/public/tmp/excel`),一旦落盘即可被猜名下载。
* 🔴 只写 contestants.teacher_name,不新建账号、不改队伍、不碰成绩。
*/
async backfillTeachers() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
const MAX_BYTES = 5 * 1024 * 1024;
try {
const stream = await ctx.getFileStream();
if (!stream || !stream.filename) {
ctx.body = ctx.helper.error(400, '请选择报名表文件');
return;
}
const ext = path.extname(stream.filename).toLowerCase();
if (!['.xlsx', '.xls'].includes(ext)) {
ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式的报名表');
return;
}
const chunks = [];
let size = 0;
for await (const chunk of stream) {
size += chunk.length;
if (size > MAX_BYTES) {
ctx.body = ctx.helper.error(400, '文件超过 5MB,请确认上传的是报名表而不是全量明细');
return;
}
chunks.push(chunk);
}
if (size === 0) {
ctx.body = ctx.helper.error(400, '文件内容为空');
return;
}
const fields = stream.fields || {};
const group = fields.group ? String(fields.group) : '';
// 只有显式传 '0'/'false' 才关闭覆盖;缺省 = 覆盖("重导一次"的语义就是取最新)
const overwrite = !(fields.overwrite === '0' || fields.overwrite === 'false');
const result = await service.admin.backfillTeachers(Buffer.concat(chunks), { group, overwrite });
if (!result.ok) {
// 业务性失败(文件不是报名表 / 缺列 / 找不到教师)⇒ code=400 + 纯人话原因。
// ⚠️ helper.error 只接受 (code, message),要带明细必须显式附加(否则第三参数被静默丢弃)
ctx.body = Object.assign(ctx.helper.error(400, result.message), { data: result });
return;
}
ctx.body = ctx.helper.success(result, result.message);
} catch (err) {
// 同上:multipart 白名单拦截时 err.status=400,不要包成 500
if (err && err.status === 400) {
ctx.body = ctx.helper.error(400, '文件格式不被允许:报名表请上传 .xlsx / .xls');
return;
}
ctx.logger.error('指导教师回填失败:', err);
ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '指导教师回填失败');
}
}
/**
* 成绩公示表导出(R176)
* GET /api/admin/export-publicity/:group
*
* 产物 = 大赛参考模板的 xlsx 半成品:数据行已填,获奖等级留空待人工填。
* ⛔ 与 exportExcel 用途不同:那个是内部核查表(含身份证号/手机号),
* 这个不含任何身份证号 / 手机号,可以直接对外公示。
*/
async exportPublicity() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
// `?meta=1` 只回统计、不下载:前端用它在点「下载」前提示
// 「本组 N 人无小组归属」「标题赛项信息为空」这类需要管理员知情的事实。
// ⚠️ 不能靠自定义响应头带这些字段 —— 生产经 X-Accel 时上游自定义头会被丢弃。
const built = await service.admin.buildPublicityXlsx(group);
if (String(ctx.query.meta || '') === '1') {
ctx.body = ctx.helper.success({
fileName: built.fileName,
rowCount: built.rowCount,
testCount: built.testCount,
noTeamCount: built.noTeamCount,
totalContestants: built.totalContestants,
itemName: built.itemName,
itemCode: built.itemCode,
});
return;
}
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
// 中文文件名:content-disposition 会输出 filename*=UTF-8''… 形式,
// nginx 的 X-Accel 只丢弃自定义头、不丢弃 Content-Disposition(实测)
ctx.attachment(built.fileName);
ctx.body = built.buffer;
} catch (err) {
ctx.logger.error('导出成绩公示表失败:', err);
ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '导出成绩公示表失败');
}
}
// ==================== 赛项信息配置(R176) ====================
/**
* 读取赛项信息
* GET /api/admin/competition-item
*
* 成绩公示表标题的第 2、3 行要用(赛项名称 / 赛项编号)。
* 用户裁定:不做硬编码,改为两项配置由管理人员在后台填写。
*/
async competitionItemInfo() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const data = await service.admin.getCompetitionItemInfo();
ctx.body = ctx.helper.success(data);
} catch (err) {
ctx.logger.error('读取赛项信息失败:', err);
ctx.body = ctx.helper.error(500, '读取赛项信息失败');
}
}
/**
* 保存赛项信息
* PUT /api/admin/competition-item
* body: { itemName?, itemCode? }
*
* ⚠️ 破坏面:覆盖 competition_info 里两个键的值。只会影响公示表标题文案,
* 不影响成绩、账号、队伍;改错重填即可。
*/
async updateCompetitionItemInfo() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const body = ctx.request.body || {};
// 长度兜底:两个值都会写进 xlsx 标题单元格,过长会把标题撑爆
for (const [k, limit] of [['itemName', 120], ['itemCode', 60]]) {
const v = body[k];
if (v !== undefined && v !== null && String(v).length > limit) {
ctx.body = ctx.helper.error(400, `${k === 'itemName' ? '赛项名称' : '赛项编号'}不能超过 ${limit} 字`);
return;
}
}
const saved = await service.admin.setCompetitionItemInfo({
itemName: body.itemName,
itemCode: body.itemCode,
});
ctx.body = ctx.helper.success(saved, '赛项信息已保存');
} catch (err) {
ctx.logger.error('保存赛项信息失败:', err);
ctx.body = ctx.helper.error(500, '保存赛项信息失败');
}
}
// ==================== 提交记录管理 ====================
/**
* 获取某组的提交记录
* GET /api/admin/submissions/:group
*
* 中职/高职组:返回答题提交详情(含题目信息)
* 本科/教师组:返回作品提交详情(含文件信息)
*/
async submissions() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
// 中职/高职组使用简化版查询(contestants + exam_submissions)
const isExamGroup = group === 'zhongzhi' || group === 'gaozhi';
const submissions = isExamGroup
? await service.admin.getExamSubmissions(group)
: await service.admin.getSubmissions(group);
ctx.body = ctx.helper.success({
group,
list: submissions,
total: submissions.length,
}, '获取提交记录成功');
} catch (err) {
ctx.logger.error('获取提交记录失败:', err);
ctx.body = ctx.helper.error(500, '获取提交记录失败');
}
}
/**
* 作品管理统一列表(覆盖全部 4 个组别)
* GET /api/admin/works/:group
*/
async works() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
const list = await service.admin.getWorks(group);
ctx.body = ctx.helper.success({ group, list, total: list.length }, '获取作品列表成功');
} catch (err) {
ctx.logger.error('获取作品列表失败:', err);
ctx.body = ctx.helper.error(500, '获取作品列表失败');
}
}
/**
* 导出提交记录为 CSV
* GET /api/admin/export-csv/:group
*
* 设置 Content-Disposition 头,触发浏览器下载
*/
async exportCsv() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
const csvData = await service.admin.exportToCsv(group);
// 设置响应头触发下载
const fileName = `${group}_submissions_${new Date().toISOString().slice(0, 10)}.csv`;
ctx.set('Content-Type', 'text/csv; charset=utf-8');
ctx.attachment(fileName);
// 添加 UTF-8 BOM 确保 Excel 正确识别中文
ctx.body = '\uFEFF' + csvData;
} catch (err) {
ctx.logger.error('导出 CSV 失败:', err);
ctx.body = ctx.helper.error(500, '导出 CSV 失败');
}
}
/**
* 导出提交记录为 Excel(.xlsx)(R29)
* GET /api/admin/export-excel/:group
*
* 测试人员反馈「导出 CSV 不如直接导出 Excel」:CSV 在 Excel 中打开时中文易乱码、
* 身份证号 / 手机号会被转成科学计数法。此处直接下发真正的 xlsx。
* ⚠️ 与 exportCsv 共用 service.admin.buildScoreExportTable(表头/口径单一数据源)。
*/
async exportExcel() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
const buf = await service.admin.exportToXlsx(group);
const fileName = `${group}_scores_${new Date().toISOString().slice(0, 10)}.xlsx`;
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
ctx.attachment(fileName);
ctx.body = buf;
} catch (err) {
ctx.logger.error('导出 Excel 失败:', err);
ctx.body = ctx.helper.error(500, '导出 Excel 失败');
}
}
/**
* 批量下载某组所有提交文件为 ZIP
* GET /api/admin/batch-download/:group
*
* 文件重命名为 "组号-学校-姓名-原文件名" 格式,流式下载 ZIP
*/
async batchDownload() {
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
const { ctx, service, app } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
const zipFilePath = await service.admin.batchDownloadZip(group);
// 🔴 同源 collapse(缺口9 修复):统一走 sendFile(磁盘校验 + X-File-Size 哨兵)。
// 临时 zip 在 app/public/tmp/zip 下,但用 absolutePath 模式并强制 Node 流(useXAccel:false),
// 避免 X-Accel「发头即 unlink」竞态损坏;清理逻辑保留在流结束后的 finish 事件。
const fileName = path.basename(zipFilePath);
await sendFile(ctx, this.app, { absolutePath: zipFilePath, fileName, contentType: 'application/zip', useXAccel: false });
// 下载完成后清理 ZIP 文件(Node 流模式下 res.finish 在流发完后触发)
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
// 🔴 R161:原实现只删 zipFilePath,**漏删 batchDownloadZip 建的 zipDir 工作目录**
// (tmp/zip/<group>/<ts>/*_chunk.pdf,实测残留 340 文件 / 4 目录 ⇒ 每次导出必泄漏)。
// 改用 safeCleanup(三态可观测,失败 logger.warn 不静默)并补删工作目录。
ctx.res.on('finish', () => {
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
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) {
ctx.logger.error('批量下载失败:', err);
// 如果 archiver 不可用,返回友好错误
if (err.code === 'MODULE_NOT_FOUND' && err.message.includes('archiver')) {
ctx.body = ctx.helper.error(500, 'ZIP 打包功能需要安装 archiver 依赖,请运行 npm install archiver');
return;
}
ctx.body = ctx.helper.error(500, '批量下载失败');
}
}
feat(upload): 组内作品唯一 + 小组编号目录 + 按小组打包下载(R190) 需求(管理人员反馈): ① 本科组/教师组同样遵循「小组内只要有一人上传作品,其他人就不能再上传」; ② 作品按「小组编号」建目录存放并保留原始文件名,管理端下载时按小组打包。 后端落地: - 组锁:新增 getTeamUploadState/assertTeamUploadAllowed,按 (group, teamUid) 取全组 提交记录,**最早提交者为归属人**;其他人上传与分片合并两条路径都返 6006 (data 带 teamLocked/teamOwnerName/teamNumber/folderName)。删除本人作品后自动解锁。 ⛔ 必须先按 group 过滤再认 teamUid(team_uid 仅组内唯一)。 - 落盘:works/<group>/<小组编号>/<原始文件名>。目录名走新增 lib/team_folder.js (唯一 SoT:净化 Windows 非法字符/控制字符/首尾点/设备保留名,限长 60)。 撞名三态:不存在→原名;同一聚合+同一 file_type→原名原子覆盖;否则 (2)(3)。 DB file_path 仍为相对路径 ⇒ 存量平铺文件免迁移。 - 直传与分片合并收敛到同一套 prepareWorkTarget + commitWorkFile:先写同目录临时名 (.upload_*/.merge_*),再 renameSync;失败一律 safeCleanup 后抛。 - status 接口新增 teamLocked/canUpload/teamOwnerName/isTeamOwner/teamFiles/memberCount。 - 管理端:works 列表返回 teamUid/teamNumber/teamFolder(走 resolveTeamLabels 既有口径); 新增 GET /api/admin/team-zip/:group?teamUid= 单组打包(仅本科/教师,其余 400,包内平铺); batch-download 包内改为 <小组编号>/<原始文件名>。两者共用 _buildZip。 验证:真接口 45/45 + 变异 M1(locked=false ⇒ 15 条断言红,还原后复绿); 已收编为门禁第 58 步。
2026-10-21 15:20:42 +08:00
// ==================== 单小组作品压缩包(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, '小组作品打包失败');
}
}
feat(upload): 组内作品唯一 + 小组编号目录 + 按小组打包下载(R190) 需求(管理人员反馈): ① 本科组/教师组同样遵循「小组内只要有一人上传作品,其他人就不能再上传」; ② 作品按「小组编号」建目录存放并保留原始文件名,管理端下载时按小组打包。 后端落地: - 组锁:新增 getTeamUploadState/assertTeamUploadAllowed,按 (group, teamUid) 取全组 提交记录,**最早提交者为归属人**;其他人上传与分片合并两条路径都返 6006 (data 带 teamLocked/teamOwnerName/teamNumber/folderName)。删除本人作品后自动解锁。 ⛔ 必须先按 group 过滤再认 teamUid(team_uid 仅组内唯一)。 - 落盘:works/<group>/<小组编号>/<原始文件名>。目录名走新增 lib/team_folder.js (唯一 SoT:净化 Windows 非法字符/控制字符/首尾点/设备保留名,限长 60)。 撞名三态:不存在→原名;同一聚合+同一 file_type→原名原子覆盖;否则 (2)(3)。 DB file_path 仍为相对路径 ⇒ 存量平铺文件免迁移。 - 直传与分片合并收敛到同一套 prepareWorkTarget + commitWorkFile:先写同目录临时名 (.upload_*/.merge_*),再 renameSync;失败一律 safeCleanup 后抛。 - status 接口新增 teamLocked/canUpload/teamOwnerName/isTeamOwner/teamFiles/memberCount。 - 管理端:works 列表返回 teamUid/teamNumber/teamFolder(走 resolveTeamLabels 既有口径); 新增 GET /api/admin/team-zip/:group?teamUid= 单组打包(仅本科/教师,其余 400,包内平铺); batch-download 包内改为 <小组编号>/<原始文件名>。两者共用 _buildZip。 验证:真接口 45/45 + 变异 M1(locked=false ⇒ 15 条断言红,还原后复绿); 已收编为门禁第 58 步。
2026-10-21 15:20:42 +08:00
// ==================== 时间管理 ====================
/**
* 批量设置比赛时间
* PUT /api/admin/times
* Body: { times: [{ group, startTime, endTime, deadline }] }
*/
async setTimes() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const rule = {
times: { type: 'array', required: true, min: 1 },
};
try {
ctx.validate(rule);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
const { times } = ctx.request.body;
// 验证每个时间配置
const validGroups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
for (const t of times) {
if (!t.group || !validGroups.includes(t.group)) {
ctx.body = ctx.helper.error(400, `无效的组别: ${t.group}`);
return;
}
}
// B② 乐观锁:客户端可回传它读到的旧值(expectedTimes);与库里现值不一致
// 即判冲突,避免后保存者静默覆盖先保存者的结果。不传 ⇒ 退回旧行为。
const expectedTimes = (ctx.request.body && ctx.request.body.expectedTimes) || null;
await service.admin.setTimes(times, { expected: expectedTimes });
ctx.body = ctx.helper.success(null, '比赛时间设置成功');
} catch (err) {
// 🔴 并发冲突是业务性保护,不是 500:必须「200 + 业务码」。
// 按 500 返回会让前端拦截器提示「服务器错误」,掩盖真实原因。
if (err && err.name === 'TimesConflictError') {
ctx.logger.warn('[setTimes] 时间配置并发冲突: %j', err.conflicts);
ctx.body = ctx.helper.error(4090, '比赛时间已被他人修改,请刷新后重试');
return;
}
ctx.logger.error('设置比赛时间失败:', err);
ctx.body = ctx.helper.error(500, '设置比赛时间失败');
}
}
/**
* 获取比赛时间配置
* GET /api/admin/times
*/
async getTimes() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const times = await service.admin.getTimes();
ctx.body = ctx.helper.success({ times });
} catch (err) {
ctx.logger.error('获取比赛时间失败:', err);
ctx.body = ctx.helper.error(500, '获取比赛时间失败');
}
}
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
/**
* 节点备份调度器状态
* 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, '获取节点备份状态失败');
}
}
// ==================== 评分管理 ====================
/**
* 批量设置评分
* PUT /api/admin/scores/:group
* Body: { scores: [{ submissionId, score }] }
*/
async setScores() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
const rule = {
scores: { type: 'array', required: true, min: 1 },
};
try {
ctx.validate(rule);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
const { scores } = ctx.request.body;
// 验证每个评分数据
for (const item of scores) {
if (!item.contestantId) {
ctx.body = ctx.helper.error(400, '评分数据格式错误,需要 contestantId');
return;
}
if (group === 'zhongzhi' || group === 'gaozhi') {
if (item.wordScore === undefined && item.examScore === undefined && item.score === undefined) {
ctx.body = ctx.helper.error(400, '评分数据格式错误,需要 wordScore(或 examScore/score)');
return;
}
} else {
if (item.wordScore === undefined && item.pptScore === undefined && item.score === undefined) {
ctx.body = ctx.helper.error(400, '评分数据格式错误,需要 wordScore/pptScore(或 score)');
return;
}
}
}
await service.admin.updateScores(scores, group);
ctx.body = ctx.helper.success(null, '评分设置成功');
} catch (err) {
ctx.logger.error('设置评分失败:', err);
ctx.body = ctx.helper.error(500, '设置评分失败');
}
}
/**
* 获取评分列表
* GET /api/admin/scores/:group
*/
async getScores() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
const scores = await service.admin.getScores(group);
ctx.body = ctx.helper.success({ list: scores });
} catch (err) {
ctx.logger.error('获取评分列表失败:', err);
ctx.body = ctx.helper.error(500, '获取评分列表失败');
}
}
// ==================== 题目管理 ====================
/**
* 批量设置/替换题目
* PUT /api/admin/exam/:group/questions
* Body: { question_type: 'choice'|'judge'|'word', questions: [...], action: 'add'|'update'|'delete' }
*
* 按题型分表管理:选择题(choice)、判断题(judge)、Word题(word)
*/
async setQuestions() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const { questions, action, question_type, score } = ctx.request.body;
const qType = question_type || 'choice';
// 批量设分(setTypeScore)/ 批量删除(bulkDelete)不需要 questions 数组;其余操作均需要
if (action !== 'setTypeScore' && action !== 'bulkDelete') {
if (!questions || !Array.isArray(questions)) {
ctx.body = ctx.helper.error(400, '参数校验失败:questions 为必填数组');
return;
}
}
if (!['choice', 'judge', 'word'].includes(qType)) {
ctx.body = ctx.helper.error(400, '题型参数无效,必须为 choice/judge/word');
return;
}
if (action === 'add') {
for (const q of questions) {
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
// 客观题(choice/judge)分值由 service 层强制为 1(赛制口径),此处不再要求
// 调用方传 score;word 题型的合法性由 service 层 _resolveQuestionScore 校验。
await service.admin.addQuestion(q, group, qType);
}
ctx.body = ctx.helper.success({ added: questions.length }, '题目添加成功');
} else if (action === 'update') {
for (const q of questions) {
if (!q.question_uid && !q.id) {
ctx.body = ctx.helper.error(400, '更新题目需要 question_uid 或 id');
return;
}
await service.admin.updateQuestion(q.question_uid || q.id, q, qType);
}
ctx.body = ctx.helper.success({ updated: questions.length }, '题目更新成功');
} else if (action === 'delete') {
for (const q of questions) {
if (!q.question_uid && !q.id) {
ctx.body = ctx.helper.error(400, '删除题目需要 question_uid 或 id');
return;
}
await service.admin.deleteQuestion(q.question_uid || q.id, qType);
}
ctx.body = ctx.helper.success({ deleted: questions.length }, '题目删除成功');
} else if (action === 'setTypeScore') {
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
// 分值口径已统一(2026-09-23):客观题(choice/judge)恒为 1 分,
// 传入的 score 被忽略;仅综合性任务(word)按传入值设置(service 层校验)。
const updated = await service.admin.setTypeScore(group, qType, score);
const typeLabel = qType === 'choice' ? '选择题' : (qType === 'judge' ? '判断题' : '综合性任务');
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
const shown = (qType === 'choice' || qType === 'judge') ? 1 : score;
ctx.body = ctx.helper.success({ updated }, `已将${typeLabel}统一设为 ${shown} 分`);
} else if (action === 'bulkDelete') {
// 批量删除题目:question_uids 数组
const ids = (ctx.request.body.question_uids || [])
.map(id => parseInt(id, 10))
.filter(id => !isNaN(id));
if (ids.length === 0) {
ctx.body = ctx.helper.error(400, '批量删除需要 question_uids 数组');
return;
}
const deleted = await service.admin.bulkDeleteQuestions(ids, qType);
ctx.body = ctx.helper.success({ deleted: ids.length }, `已删除 ${ids.length} 道题目`);
} else {
// 默认:替换该题型全部题目
await service.admin.manageQuestions(questions, group, qType);
ctx.body = ctx.helper.success({ count: questions.length }, '题目设置成功');
}
} catch (err) {
ctx.logger.error('题目设置失败:', err);
ctx.body = ctx.helper.error(500, '题目设置失败');
}
}
/**
* 获取考试题目列表(按题型分表返回)
* GET /api/admin/exam/:group/questions
* 返回: { choice: [...], judge: [...], word: [...] }
*/
async getQuestions() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const questions = await service.admin.getQuestions(group);
ctx.body = ctx.helper.success(questions);
} catch (err) {
ctx.logger.error('获取题目列表失败:', err);
ctx.body = ctx.helper.error(500, '获取题目列表失败');
}
}
/**
* 客观题 Excel 批量导入
* POST /api/admin/exam/:group/questions/import-excel
* multipart/form-data: file 字段为 .xlsx/.xls
* 单工作表,含列:题型、题目、选项A、选项B、选项C、选项D、正确答案、分值
*/
async importQuestionsExcel() {
const { ctx, service, app } = this;
if (!this._ensureAdmin()) return;
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
// 🔴 R161:须在 try 外声明,finally 才可见(原来没有 finally ⇒ 抛错即泄漏)。
let tempFilePath = null;
try {
const stream = await ctx.getFileStream();
if (!stream) {
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 tempDir = path.join(app.baseDir, 'app/public/tmp/excel');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
tempFilePath = path.join(tempDir, `questions_import_${Date.now()}${ext}`);
const writeStream = fs.createWriteStream(tempFilePath);
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
stream.on('error', reject);
});
const result = await service.admin.importQuestionsFromExcel(tempFilePath, { group });
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`);
} catch (err) {
ctx.logger.error('客观题 Excel 导入失败:', err);
const msg = (err && err.message) ? err.message : '未知错误';
// 前端 onExcelFileChange 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀
ctx.body = ctx.helper.error(500, msg);
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
} finally {
// 🔴 R161:原实现把 unlink 写在 `await importQuestionsFromExcel` 之后、catch 之前
// ⇒ 解析/导入一抛错即永久泄漏 tmp/excel/questions_import_*.xlsx。
// 移入 finally + 改 safeCleanup(三态可观测,失败 logger.warn 不静默)。
if (tempFilePath) {
ctx.helper.safeCleanup(tempFilePath, { label: 'importQuestions-tmp' });
}
}
}
/**
* 下载客观题 Excel 导入模板
* GET /api/admin/exam/:group/questions/template
*/
async downloadQuestionTemplate() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const buffer = await service.admin.buildQuestionTemplate();
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
ctx.set('Content-Disposition', 'attachment; filename="objective_questions_template.xlsx"');
ctx.body = buffer;
} catch (err) {
ctx.logger.error('下载题目模板失败:', err);
ctx.body = ctx.helper.error(500, '下载题目模板失败');
}
}
// ==================== Word 模板管理 ====================
/**
* 上传 Word 模板文件
* POST /api/admin/upload-word-template/:group
*
* 上传并更新 competition_times 中的 word_template 字段
*/
async uploadWordTemplate() {
const { ctx, service, app } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 获取上传的文件流
const stream = await ctx.getFileStream();
if (!stream) {
ctx.body = ctx.helper.error(400, '未接收到文件');
return;
}
// 验证文件格式
const ext = path.extname(stream.filename).toLowerCase();
if (!['.doc', '.docx'].includes(ext)) {
ctx.body = ctx.helper.error(400, '仅支持 .doc / .docx 格式');
return;
}
// 保存文件到模板目录
const templateDir = path.join(app.baseDir, 'app/public/templates', group);
if (!fs.existsSync(templateDir)) {
fs.mkdirSync(templateDir, { recursive: true });
}
const timestamp = Date.now();
const safeFileName = `word_template_${timestamp}${ext}`;
const filePath = path.join(templateDir, safeFileName);
const writeStream = fs.createWriteStream(filePath);
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
stream.on('error', reject);
});
// 构建相对路径(相对于 app/public/)
const relativePath = path.join('templates', group, safeFileName).replace(/\\/g, '/');
// 更新数据库
await service.admin.uploadWordTemplate(group, { filePath: relativePath });
ctx.body = ctx.helper.success({
group,
filePath: relativePath,
fileUrl: ctx.helper.fileUrl(relativePath),
fileName: stream.filename,
}, 'Word 模板上传成功');
} catch (err) {
ctx.logger.error('上传 Word 模板失败:', err);
ctx.body = ctx.helper.error(500, '上传 Word 模板失败');
}
}
// ==================== 成绩发布管理 ====================
/**
* 发布/撤销成绩
* PUT /api/admin/publish-scores
* Body: { publish: true/false, group?: 'zhongzhi'|'gaozhi'|'benke'|'jiaoshi' }
*
* group 可选:带组别时仅对该组生效(管理后台「发布成绩」按钮即按组操作);
* 不带时对全部组生效,兼容旧调用方。
*/
async publishScores() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const rule = {
publish: { type: 'boolean', required: true },
};
try {
ctx.validate(rule);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
const { publish, group } = ctx.request.body;
const validGroups = [ 'zhongzhi', 'gaozhi', 'benke', 'jiaoshi' ];
if (group && !validGroups.includes(group)) {
ctx.body = ctx.helper.error(400, '无效的组别');
return;
}
await service.admin.publishScores(publish, group);
// R178 审计:发布/撤销成绩会直接改变「考生能不能看到分数」,
// 且发布还会自动生成公示草稿 —— 属于必须能追溯到人的动作。
await recordAudit(
ctx,
publish ? AUDIT_ACTIONS.SCORE_PUBLISH : AUDIT_ACTIONS.SCORE_UNPUBLISH,
group || 'all',
{ group: group || null, scope: group ? 'single' : 'all' }
);
const scopeLabel = group ? '该组成绩' : '全部组成绩';
ctx.body = ctx.helper.success(
{ published: publish, group: group || null },
publish ? `${scopeLabel}已发布` : `${scopeLabel}已撤销发布`
);
} catch (err) {
ctx.logger.error('发布成绩失败:', err);
ctx.body = ctx.helper.error(500, '操作失败');
}
}
/**
* 查询成绩是否已发布
* GET /api/admin/publish-scores?group=xxx
*
* 带 group 返回该组状态;不带时 published 表示「四个组是否全部已发布」,
* 并额外返回 groups 明细供管理后台一次性渲染各组按钮。
*/
async getPublishStatus() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.query;
const groups = await service.admin.getScorePublishMap();
const published = group
? await service.admin.isScorePublished(group)
: await service.admin.isScorePublished();
ctx.body = ctx.helper.success({ published, group: group || null, groups });
} catch (err) {
ctx.logger.error('查询成绩发布状态失败:', err);
ctx.body = ctx.helper.error(500, '查询失败');
}
}
// ==================== 新闻管理 ====================
/**
* 创建新闻
* POST /api/admin/news
* Body: { title, content, wx_link, publish_time, pinned, group_name }
*/
async createNews() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const rule = {
title: { type: 'string', required: true },
content: { type: 'string', required: false },
};
try {
ctx.validate(rule);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
const news = await service.admin.createNews(ctx.request.body);
ctx.body = ctx.helper.success(news, '新闻创建成功');
} catch (err) {
ctx.logger.error('创建新闻失败:', err);
ctx.body = ctx.helper.error(500, '创建新闻失败');
}
}
/**
* 更新新闻
* PUT /api/admin/news/:id
*/
async updateNews() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { id } = ctx.params;
const news = await service.admin.updateNews(id, ctx.request.body);
ctx.body = ctx.helper.success(news, '新闻更新成功');
} catch (err) {
if (err.message === '新闻不存在') {
ctx.body = ctx.helper.error(404, '新闻不存在');
return;
}
ctx.logger.error('更新新闻失败:', err);
ctx.body = ctx.helper.error(500, '更新新闻失败');
}
}
/**
* 删除新闻
* DELETE /api/admin/news/:id
*/
async deleteNews() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { id } = ctx.params;
await service.admin.deleteNews(id);
ctx.body = ctx.helper.success(null, '新闻删除成功');
} catch (err) {
ctx.logger.error('删除新闻失败:', err);
ctx.body = ctx.helper.error(500, '删除新闻失败');
}
}
/**
* 更新新闻排序
* PUT /api/admin/news/order
* Body: { orders: [{ id, sort_order }] }
*/
async updateNewsOrder() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const rule = {
orders: { type: 'array', required: true, min: 1 },
};
try {
ctx.validate(rule);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
const { orders } = ctx.request.body;
await service.news.updateSortOrder(orders);
ctx.body = ctx.helper.success(null, '新闻排序更新成功');
} catch (err) {
ctx.logger.error('更新新闻排序失败:', err);
ctx.body = ctx.helper.error(500, '更新新闻排序失败');
}
}
/**
* 获取新闻列表(管理员视图)
* GET /api/admin/news
*/
async listNews() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const news = await service.admin.listNews();
ctx.body = ctx.helper.success({ list: news });
} catch (err) {
ctx.logger.error('获取新闻列表失败:', err);
ctx.body = ctx.helper.error(500, '获取新闻列表失败');
}
}
// ==================== 轮播图管理 ====================
/**
* 添加轮播图
* POST /api/admin/carousel
* Body: { image_url, title, sort_order, link_url, enabled }
*/
async addCarousel() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
// image_url 可为空(无底图时前端回退渐变);仅做类型校验,不强制必填
const rule = {
image_url: { type: 'string', required: false, allowEmpty: true },
};
try {
ctx.validate(rule);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
const carousel = await service.admin.addCarousel(ctx.request.body);
ctx.body = ctx.helper.success(carousel, '轮播图添加成功');
} catch (err) {
ctx.logger.error('添加轮播图失败:', err);
ctx.body = ctx.helper.error(500, '添加轮播图失败');
}
}
/**
* 更新轮播图
* PUT /api/admin/carousel/:id
*/
async updateCarousel() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { id } = ctx.params;
const carousel = await service.admin.updateCarousel(id, ctx.request.body);
ctx.body = ctx.helper.success(carousel, '轮播图更新成功');
} catch (err) {
if (err.message === '轮播图不存在') {
ctx.body = ctx.helper.error(404, '轮播图不存在');
return;
}
ctx.logger.error('更新轮播图失败:', err);
ctx.body = ctx.helper.error(500, '更新轮播图失败');
}
}
/**
* 删除轮播图
* DELETE /api/admin/carousel/:id
*/
async deleteCarousel() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { id } = ctx.params;
await service.admin.deleteCarousel(id);
ctx.body = ctx.helper.success(null, '轮播图删除成功');
} catch (err) {
ctx.logger.error('删除轮播图失败:', err);
ctx.body = ctx.helper.error(500, '删除轮播图失败');
}
}
/**
* 获取轮播图列表
* GET /api/admin/carousel
*/
async listCarousel() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const carousels = await service.admin.listCarousel();
ctx.body = ctx.helper.success({ list: carousels });
} catch (err) {
ctx.logger.error('获取轮播图列表失败:', err);
ctx.body = ctx.helper.error(500, '获取轮播图列表失败');
}
}
/**
* 上传图片(轮播图等)
* POST /api/admin/upload/image
*/
async uploadImage() {
const { ctx } = this;
if (!this._ensureAdmin()) return;
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 (!['.png', '.jpg', '.jpeg', '.gif', '.webp'].includes(ext)) {
ctx.body = ctx.helper.error(400, '仅支持 PNG/JPG/JPEG/GIF/WebP 格式');
return;
}
const fs = require('fs');
const app = this.app;
const uploadDir = path.join(app.baseDir, 'app/public/carousel');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const filename = `carousel_${Date.now()}${ext}`;
const destPath = path.join(uploadDir, filename);
// 写入文件流,同时检查文件大小
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
let totalSize = 0;
const ws = fs.createWriteStream(destPath);
stream.on('data', (chunk) => {
totalSize += chunk.length;
if (totalSize > MAX_SIZE) {
stream.destroy();
ws.destroy();
// 清理已写入的部分文件
try { fs.unlinkSync(destPath); } catch (e) {}
return;
}
});
stream.pipe(ws);
await new Promise((resolve, reject) => {
ws.on('finish', resolve);
ws.on('error', reject);
});
// 检查最终大小
if (totalSize > MAX_SIZE) {
try { fs.unlinkSync(destPath); } catch (e) {}
ctx.body = ctx.helper.error(400, '图片文件大小不能超过 5MB');
return;
}
const imageUrl = `/public/carousel/${filename}`;
ctx.body = ctx.helper.success({ imageUrl, filename }, '图片上传成功');
} catch (err) {
ctx.logger.error('上传图片失败:', err);
ctx.body = ctx.helper.error(500, '上传图片失败');
}
}
async uploadNewsImage() {
const { ctx } = this;
if (!this._ensureAdmin()) return;
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 (!['.png', '.jpg', '.jpeg', '.gif', '.webp'].includes(ext)) {
ctx.body = ctx.helper.error(400, '仅支持 PNG/JPG/JPEG/GIF/WebP 格式');
return;
}
const fs = require('fs');
const app = this.app;
const uploadDir = path.join(app.baseDir, 'app/public/news');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const filename = `news_${Date.now()}${ext}`;
const destPath = path.join(uploadDir, filename);
const ws = fs.createWriteStream(destPath);
stream.pipe(ws);
await new Promise((resolve, reject) => {
ws.on('finish', resolve);
ws.on('error', reject);
});
const imageUrl = `/public/news/${filename}`;
ctx.body = ctx.helper.success({ imageUrl, filename }, '图片上传成功');
} catch (err) {
ctx.logger.error('上传新闻图片失败:', err);
ctx.body = ctx.helper.error(500, '上传新闻图片失败');
}
}
/**
* 上传新闻附件(R176)
* POST /api/admin/upload/news-file
*
* 用户裁定:成绩公示改为「新闻附件」形态,所以新闻要支持文件下载,
* 而不是把下载链接手写进正文(正文一旦人工编辑,链接极易被删掉)。
*
* 与 uploadNewsImage 复用同一目录(`app/public/news`)与同一访问路径前缀,
* 区别只在扩展名白名单与大小上限。
*
* @returns {{fileUrl: string, fileName: string, size: number, filename: string}}
*/
async uploadNewsFile() {
const { ctx } = this;
if (!this._ensureAdmin()) return;
const ALLOWED_EXT = ['.xlsx', '.xls', '.pdf', '.docx', '.doc', '.zip'];
const MAX_BYTES = 20 * 1024 * 1024;
try {
const stream = await ctx.getFileStream();
if (!stream || !stream.filename) {
ctx.body = ctx.helper.error(400, '请选择要上传的附件');
return;
}
const ext = path.extname(stream.filename).toLowerCase();
if (ALLOWED_EXT.indexOf(ext) < 0) {
ctx.body = ctx.helper.error(400, `仅支持 ${ALLOWED_EXT.join(' / ')} 格式的附件`);
return;
}
const chunks = [];
let size = 0;
for await (const chunk of stream) {
size += chunk.length;
if (size > MAX_BYTES) {
ctx.body = ctx.helper.error(400, '附件超过 20MB,请压缩后再上传');
return;
}
chunks.push(chunk);
}
if (size === 0) {
ctx.body = ctx.helper.error(400, '附件内容为空');
return;
}
const fs = require('fs');
const uploadDir = path.join(this.app.baseDir, 'app/public/news');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 存盘文件名与展示名分离:存盘用时间戳(避免覆盖与路径穿越),
// 展示名保留原始中文名,写进 news.attachment_name 供下载按钮显示。
const storedName = `news_file_${Date.now()}${ext}`;
fs.writeFileSync(path.join(uploadDir, storedName), Buffer.concat(chunks));
// 🔴 multipart 的 filename 常按 latin1 解出(中文变乱码)。
// 启发式:按 latin1 取回字节后若能完整 UTF-8 解码且含非 ASCII,就用解码结果。
let displayName = String(stream.filename);
const asUtf8 = Buffer.from(displayName, 'latin1').toString('utf8');
if (asUtf8.indexOf('\uFFFD') < 0 && /[^\x00-\x7F]/.test(asUtf8)) {
displayName = asUtf8;
}
// 去掉可能带进来的路径部分(Windows 上传会带 C:\fakepath\)
displayName = displayName.replace(/^.*[\\/]/, '').slice(0, 200);
ctx.body = ctx.helper.success({
fileUrl: `/public/news/${storedName}`,
fileName: displayName,
size,
filename: storedName,
}, '附件上传成功');
} catch (err) {
// 🔴 multipart 层(egg-multipart 的 whitelist)会先于本方法抛 400,
// 统一转成人话;否则前端看到的是一句「上传新闻附件失败」+ 500,
// 管理员无从知道是「格式不支持」。
if (err && err.status === 400) {
ctx.body = ctx.helper.error(400, `文件格式不被允许,仅支持 ${ALLOWED_EXT.join(' / ')}`);
return;
}
ctx.logger.error('上传新闻附件失败:', err);
ctx.body = ctx.helper.error(500, '上传新闻附件失败');
}
}
/**
* 下载考生答案 Word 文件
* GET /api/admin/submissions/:group/:id/word
*/
async downloadWord() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group, id } = ctx.params;
const fs = require('fs');
const app = this.app;
const filePathCache = require('../lib/file_path_cache');
// 下载前 file_path 短缓存(进程内 30s,任务 #23):高并发下载同一 Word 时减少重复 DB 查询。
// 命中缓存仍走 _sendFile 的磁盘存在性校验,文件已删则 404,不会吐错误内容。
const wordCacheKey = `word:${group}:${id}`;
const wordCached = filePathCache.getFull(wordCacheKey);
if (wordCached) {
await this._sendFile(ctx, app, wordCached.filePath, wordCached.fileName || path.basename(wordCached.filePath),
'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
return;
}
// 中职/高职组:从 exam_submissions 表查询
const isExamGroup = group === 'zhongzhi' || group === 'gaozhi';
let wordFilePath, wordFileName;
if (isExamGroup) {
const submission = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: id },
attributes: ['word_file_path', 'word_file_name'],
});
if (!submission || !submission.word_file_path) {
ctx.body = ctx.helper.error(404, '未找到该考生的 Word 答案文件');
return;
}
wordFilePath = submission.word_file_path;
wordFileName = submission.word_file_name;
} else {
// 本科/教师组:Word 答案同样存于 exam_submissions,
// 与 saveWordFile 约定一致——以 aggregation_uid 作为 contestant_id 存储
const submission = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: id },
attributes: ['word_file_path', 'word_file_name'],
});
if (!submission || !submission.word_file_path) {
ctx.body = ctx.helper.error(404, '未找到该考生的 Word 答案文件');
return;
}
wordFilePath = submission.word_file_path;
wordFileName = submission.word_file_name;
}
const filePath = path.join(app.baseDir, 'app/public', wordFilePath);
if (!fs.existsSync(filePath)) {
app.logger.error(
`[downloadWord] Word 文件缺失 group=${group} id=${id} ` +
`DB.word_file_path=${wordFilePath} 解析绝对路径=${filePath}`
);
ctx.body = ctx.helper.error(404, `Word 文件不存在(期望路径: ${wordFilePath})`);
return;
}
const fileName = wordFileName || path.basename(filePath);
filePathCache.setFull(wordCacheKey, wordFilePath, wordFileName);
// 🔴 X-Accel-Redirect 根治:生产经 nginx 内部重定向吐文件,开发回退流式。
// 文件名编码与路径穿越防护、文件 IO 并发护栏统一在 _sendFile 内处理(见其注释)。
await this._sendFile(
ctx, app, wordFilePath, fileName,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
);
} catch (err) {
ctx.logger.error('下载 Word 文件失败:', err);
ctx.body = ctx.helper.error(500, '下载失败');
}
}
/**
* 下载本科/教师组作品文件(按 submission id)
* GET /api/admin/submission-file/:group/:id
*/
async downloadSubmissionFile() {
const { ctx, app } = this;
if (!this._ensureAdmin()) return;
try {
const { group, id } = ctx.params;
if (!this._validateGroup(group, true)) return;
const filePathCache = require('../lib/file_path_cache');
// 下载前 file_path 短缓存(进程内 30s,任务 #23):命中缓存仍走 _sendFile 磁盘存在性校验。
const subCacheKey = `sub:${group}:${id}`;
const subCached = filePathCache.getFull(subCacheKey);
if (subCached) {
await this._sendFile(ctx, app, subCached.filePath, subCached.fileName || path.basename(subCached.filePath), 'application/octet-stream');
return;
}
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const submission = await SubmissionModel.findOne({ where: { id } });
if (!submission || !submission.file_path) {
ctx.body = ctx.helper.error(404, '未找到该作品文件');
return;
}
const filePath = path.join(app.baseDir, 'app/public', submission.file_path);
if (!fs.existsSync(filePath)) {
// 🔴 关键诊断信息:把「按 DB 路径解析出的绝对路径」打进日志+响应,
// 生产排查时直接告诉我们文件究竟被期望放在哪里、实际缺失(多见于
// app/public 目录缺失 / 重装后端清空了上传目录但 DB 仍记旧路径)。
app.logger.error(
`[downloadSubmissionFile] 文件缺失 group=${group} id=${id} ` +
`DB.file_path=${submission.file_path} 解析绝对路径=${filePath}`
);
ctx.body = ctx.helper.error(404, `文件不存在或已被清理(期望路径: ${submission.file_path})`);
return;
}
const fileName = submission.file_name || path.basename(filePath);
filePathCache.setFull(subCacheKey, submission.file_path, submission.file_name);
// 🔴 X-Accel-Redirect 根治:生产经 nginx 内部重定向吐文件,开发回退流式。
// 文件名编码与路径穿越防护、文件 IO 并发护栏统一在 _sendFile 内处理(见其注释)。
await this._sendFile(ctx, app, submission.file_path, fileName, 'application/octet-stream');
} catch (err) {
ctx.logger.error('下载作品文件失败:', err);
ctx.body = ctx.helper.error(500, '下载失败');
}
}
// ==================== Word 模板下载 ====================
/**
* 获取 Word 模板下载链接
* GET /api/admin/exam/:group/word-template
*/
async getWordTemplate() {
const { ctx, app } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const time = await ctx.model.CompetitionTime.findOne({
where: { group },
attributes: ['word_template'],
});
if (!time || !time.word_template) {
ctx.body = ctx.helper.error(404, 'Word 模板不存在');
return;
}
const filePath = path.join(app.baseDir, 'app/public', time.word_template);
if (!fs.existsSync(filePath)) {
ctx.body = ctx.helper.error(404, 'Word 模板文件不存在');
return;
}
ctx.body = ctx.helper.success({
templateUrl: ctx.helper.fileUrl(time.word_template),
}, '获取 Word 模板成功');
} catch (err) {
ctx.logger.error('获取 Word 模板失败:', err);
ctx.body = ctx.helper.error(500, '获取 Word 模板失败');
}
}
// ==================== Word 模板删除 ====================
/**
* 删除 Word 模板
* DELETE /api/admin/exam/:group/word-template
*/
async deleteWordTemplate() {
const { ctx, app } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 查找并清空 word_template 字段
const time = await ctx.model.CompetitionTime.findOne({
where: { group },
attributes: ['id', 'word_template'],
});
if (time && time.word_template) {
// 删除物理文件
const filePath = path.join(app.baseDir, 'app/public', time.word_template);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (e) {
ctx.logger.warn('删除 Word 模板文件失败:', e.message);
}
// 清空数据库记录
await time.update({ word_template: null });
}
ctx.body = ctx.helper.success(null, 'Word 模板已删除');
} catch (err) {
ctx.logger.error('删除 Word 模板失败:', err);
ctx.body = ctx.helper.error(500, '删除 Word 模板失败');
}
}
// ==================== 备赛资料管理 ====================
/**
* 获取备赛资料列表
* GET /api/admin/materials/:group
*/
async getMaterials() {
const { ctx, app } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
const validGroups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
if (!validGroups.includes(group)) {
ctx.body = ctx.helper.error(400, `无效的组别: ${group}`);
return;
}
const materialsDir = path.join(app.baseDir, 'app/public', 'materials', group);
if (!fs.existsSync(materialsDir)) {
ctx.body = ctx.helper.success({ list: [] }, '获取备赛资料列表成功');
return;
}
// 逐文件 stat,单个文件 stat 失败(坏软链/权限/被占用)只跳过该文件并告警,
// 绝不能让一个坏文件把整份列表拖成空数组——否则「刚上传的文件也不显示」。
const files = [];
for (const f of fs.readdirSync(materialsDir)) {
if (f.startsWith('.')) continue;
try {
const stat = fs.statSync(path.join(materialsDir, f));
files.push({
fileName: f,
fileSize: stat.size,
uploadTime: stat.mtime.toISOString(),
});
} catch (e) {
app.logger.warn(
`[admin.getMaterials] 跳过无法 stat 的文件 group=${group} file=${f}:` + (e && e.message ? e.message : e)
);
}
}
files.sort((a, b) => new Date(b.uploadTime) - new Date(a.uploadTime));
ctx.body = ctx.helper.success({ list: files }, '获取备赛资料列表成功');
} catch (err) {
ctx.logger.error('获取备赛资料列表失败:', err);
// 纵深防御:磁盘异常(权限/EACCES、坏软链、个别文件 stat 失败)不应让整个
// 管理页面 500。降级返回空列表,真实错误已落日志供运维排查(与 materials.js
// index() 的容错保持一致,避免读盘异常被 auth 中间件伪装成 401/500 踢人)。
ctx.body = ctx.helper.success({ list: [] }, '获取备赛资料列表成功(目录异常,已降级为空)');
}
}
/**
* 上传备赛资料
* POST /api/admin/materials/:group
*/
async uploadMaterial() {
const { ctx, app } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
const validGroups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
if (!validGroups.includes(group)) {
ctx.body = ctx.helper.error(400, `无效的组别: ${group}`);
return;
}
const stream = await ctx.getFileStream();
if (!stream) {
ctx.body = ctx.helper.error(400, '未接收到文件');
return;
}
const materialsDir = path.join(app.baseDir, 'app/public', 'materials', group);
if (!fs.existsSync(materialsDir)) {
fs.mkdirSync(materialsDir, { recursive: true });
}
// 文件名归一化:解码百分号、NFC、去控制字符/路径符、折叠空白并 trim,
// 防止「前导空格 + 乱码」文件名导致后续读盘 stat ENOENT(2026-08-10 事故根因)。
const rawName = stream.filename || 'material';
let safeFileName = sanitizeUploadFileName(rawName, path.extname(rawName));
// 清洗后为空(极端情况)回退到时间戳名,避免写出空名文件
if (!safeFileName) {
safeFileName = `material_${Date.now()}${path.extname(rawName) || '.bin'}`;
}
const filePath = path.join(materialsDir, safeFileName);
const writeStream = fs.createWriteStream(filePath);
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
stream.on('error', reject);
});
ctx.body = ctx.helper.success({
fileName: safeFileName,
fileSize: fs.statSync(filePath).size,
}, '备赛资料上传成功');
} catch (err) {
ctx.logger.error('上传备赛资料失败:', err);
ctx.body = ctx.helper.error(500, '上传备赛资料失败');
}
}
/**
* 删除备赛资料
* DELETE /api/admin/materials/:group/:fileName
*/
async deleteMaterial() {
const { ctx, app } = this;
if (!this._ensureAdmin()) return;
try {
const { group, fileName } = ctx.params;
const decodedFileName = decodeURIComponent(fileName);
const validGroups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
if (!validGroups.includes(group)) {
ctx.body = ctx.helper.error(400, `无效的组别: ${group}`);
return;
}
// 防止路径穿越攻击
if (decodedFileName.includes('..') || decodedFileName.includes('/') || decodedFileName.includes('\\')) {
ctx.body = ctx.helper.error(400, '无效的文件名');
return;
}
const filePath = path.join(app.baseDir, 'app/public', 'materials', group, decodedFileName);
if (!fs.existsSync(filePath)) {
ctx.body = ctx.helper.error(404, '文件不存在');
return;
}
fs.unlinkSync(filePath);
ctx.body = ctx.helper.success(null, '备赛资料已删除');
} catch (err) {
ctx.logger.error('删除备赛资料失败:', err);
ctx.body = ctx.helper.error(500, '删除备赛资料失败');
}
}
// ==================== 轮播图排序 ====================
/**
* 批量更新轮播图排序
* PUT /api/admin/carousel/order
* Body: { orders: [{ id, sort_order }] }
*/
async updateCarouselOrder() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const rule = {
orders: { type: 'array', required: true, min: 1 },
};
try {
ctx.validate(rule);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
const { orders } = ctx.request.body;
// 批量更新排序
const updatePromises = orders.map(item =>
ctx.model.Carousel.update(
{ sort_order: item.sort_order },
{ where: { id: item.id } }
)
);
await Promise.all(updatePromises);
ctx.body = ctx.helper.success(null, '轮播图排序更新成功');
} catch (err) {
ctx.logger.error('更新轮播图排序失败:', err);
ctx.body = ctx.helper.error(500, '更新轮播图排序失败');
}
}
// ==================== 测试数据标记 ====================
/**
* 切换选手的测试数据标记
* POST /api/admin/toggle-test-flag
* Body: { id, test_flag }
*
* 将选手标记为测试数据(test_flag=1)或生产数据(test_flag=0)
* 重置操作仅影响 test_flag=1 的选手及其提交记录
*/
async toggleTestFlag() {
const { ctx } = this;
if (!this._ensureAdmin()) return;
try {
const { id, test_flag } = ctx.request.body;
if (!id) {
ctx.body = ctx.helper.error(400, '缺少选手ID');
return;
}
if (test_flag !== 0 && test_flag !== 1) {
ctx.body = ctx.helper.error(400, 'test_flag 必须为 0 或 1');
return;
}
const contestant = await ctx.model.Contestant.findByPk(id);
if (!contestant) {
ctx.body = ctx.helper.error(404, '选手不存在');
return;
}
await contestant.update({ test_flag });
const label = test_flag === 1 ? '测试数据' : '生产数据';
ctx.body = ctx.helper.success({ id, test_flag }, `已标记为${label}`);
} catch (err) {
ctx.logger.error('切换测试标记失败:', err);
ctx.body = ctx.helper.error(500, '切换测试标记失败');
}
}
// ==================== 测试数据重置 ====================
/**
* 重置测试数据(方便重复测试)
* POST /api/admin/reset-test-data
*
* 安全机制:
* - 生产环境(NODE_ENV=production)禁止重置
* - 仅重置 test_flag=1 的选手及其提交记录
* - 生产数据(test_flag=0)不受影响
*
* 重置内容:
* - 选手:activated 设为 true(可直接以默认口令登录重测)、password 恢复为默认口令(bcrypt)、score 清空
* - 提交记录:exam_submissions / work_submissions 删除
* - 旧版提交记录:zhongzhi/gaozhi/benke/jiaoshi_submissions 删除
*/
async resetTestData() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
// 生产环境禁止重置
if (process.env.NODE_ENV === 'production') {
ctx.body = ctx.helper.error(403, '生产环境禁止重置测试数据');
return;
}
try {
const results = {};
// 获取所有测试选手的 ID
const testContestants = await ctx.model.Contestant.findAll({
where: { test_flag: 1 },
attributes: ['id'],
});
const testIds = testContestants.map(c => c.id);
results.testContestantsFound = testIds.length;
if (testIds.length === 0) {
ctx.body = ctx.helper.success(results, '没有找到测试数据,无需重置(请先将选手标记为测试数据)');
return;
}
// 默认口令(bcrypt)。注意:重置同时把 activated 归零(见下方第 1 步,用户反馈 Req2),
// 因此测试人员需先重新走「短信激活」,激活后再以该口令登录重测——不是直连登录。
// (此处曾误写「可直接以该口令登录重测」,与实际 activated:false 自相矛盾,已校正。
// 权威断言:scripts/verify_feedback_3.py:70,74 与 scripts/verify_testdata_mgmt.py。)
security(config): 四个同族兜底缺陷一次收口 —— APP_KEYS / 弱口令 / IPv6 兜底 / 本地生产密钥同值(R106-D6~D9) 共同根因(R104 结论的延续):**同族问题各自实现必然漂移**。 本仓 DB_PASSWORD 与 JWT_SECRET(R105-D1)已统一为「缺凭据明确失败」, 但同族的另三处仍停在「可预测的公开兜底」上,另新增一处跨环境密钥同值。 D6 · APP_KEYS 派生兜底(原 config.default.js:157) 原:const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026'; config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS; 「派生」不等于安全:由 appInfo.name 可推算 ⇒ 等价于「Cookie 签名密钥公开 ⇒ 任何人可伪造已签名 Cookie」。收口到 _secret.cjs 唯一解析入口: · scripts/_secret.cjs 抽出通用 _loadSecret(),新增 loadAppKeys() (① env → ② scripts/.creds.local.json:app_keys → ③ 明确失败,⛔ 无兜底) · 生产安全守卫移除 config.keys === DEFAULT_APP_KEYS 比对分支(已无可比兜底值) · scripts/_creds.py 与 .creds.local.example.json 同步键面(两侧同源,永不漂移) D7 · 生产弱口令兜底(原 config.default.js:176) 原:config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456'; 而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD 一直是**注释状态** ⇒ 生产 实际取到的就是弱口令 '123456'。一旦走到「批量写默认口令」的路径(重置测试数据 / 显式 testFlag=true 的导入)会批量设弱口令 —— 属**静默弱口令**而非明确失败。 修法:生产(config.isProduction)未显式注入 ⇒ 置 null;使用点改走新增的 app/lib/passwordPolicy.requireTestPassword() 明确失败。测试环境语义逐字不变。 D8 · 本地 DB 兜底 host 引发 IPv6 建连超时(原 config.default.js:217) 原:host: process.env.DB_HOST || 'localhost'。Node >= 18 的 verbatim DNS 可能把 localhost 解析到 ::1,而本机 MariaDB 只听 IPv4 ⇒ **建连 ETIMEDOUT**(不报 1045、 不报 ECONNREFUSED),现象是「后端启动卡在 DB」,极易误判为「服务没起来」。 且本仓 scripts/_db.cjs:43 的兜底早已是 '127.0.0.1' —— 同子系统两套默认值必然漂移。 修法:兜底改为 IPv4 字面量(生产显式注入 DB_HOST ⇒ 逐字不变)。 配套(config.local.js 为 gitignored 本地文件,不入库):补 host/port 覆盖 —— 「第二道保险」原本只保账号/口令,反而漏掉了真正会出问题的连接目标。 D9 · 本地 dev 与生产共用同一 JWT/APP_KEYS(loadDeployEnv) 非生产跳过清单原本只含 DB_/REDIS_/ALIYUN_/SMS_,注释称「仍保留 JWT_SECRET / APP_KEYS …… 本地无副作用」—— 该结论不成立:本地进程持有 **生产签名密钥** ⇒ 本地 token 可冒充生产用户(反之亦然),两套环境的会话边界 实际被打通,属**安全属性**缺陷。 修法:新增 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过(⛔ 不用前缀以免误伤),本地改由 scripts/.creds.local.json 供值;Python 侧 scripts/_fixtures.py resolve_jwt_secret() 同步为「非生产优先本仓凭据文件」—— 否则脚本会用生产密钥签 token 交给本地后端 验签,必然 401(正是该函数注释里记录过的 23 项级联假红)。 行为等价性(生产逐字不变,已逐项实测): · D6/D8:生产显式注入 APP_KEYS / DB_HOST ⇒ 取值路径不变 · D7:生产 resetTestData 本有 403 守卫、导入默认 testFlag=false ⇒ 原不可达 · D9:isProdEnv=true ⇒ 不跳过任何键 ⇒ 生产加载路径不变 验证: · 配置层双环境断言:本地 jwt=8066e07d5683 / keys=e4cfe40ff3ea(≠ 生产 a05591750093 / ae0d155385c1);生产两值逐字不变;生产 testDefaultPassword=null · 本地后端真实重启(仅给 DB_PORT=3307、**不给 DB_HOST**)⇒ 启动成功, /api/health {"code":0,...,"db":"ok"};日志显示跳过 16 个键(含 JWT_SECRET/APP_KEYS) · D9 判决点 d9_verify_split.py:本地密钥签 token ⇒ 200;生产密钥 ⇒ **401** · 生产部署后 r106_prod_sentinel.js:pass=11 fail=0;/api/health 200 · 门禁:verify_no_secrets.py VERIFY_ALL_PASS(HIGH 0,判别力自证 17/17); verify_script_health.py H1–H4 PASS
2026-10-12 11:06:01 +08:00
// 🔴 R106-D7:走唯一入口 —— 生产未显式注入 TEST_DEFAULT_PASSWORD 时明确失败。
// (本函数上方另有 `NODE_ENV === 'production'` 的 403 守卫;两者口径互补:
// 守卫拦「明确的生产」,本函数兜住 `APP_ENV=production` 但 NODE_ENV 未设的分叉情形。)
const testPw = requireTestPassword(ctx.app.config);
const defaultHash = await ctx.helper.hashPassword(testPw);
// 事务包裹全部写操作:任意一步失败整体回滚,避免「部分重置」脏状态
// (例如选手状态已清零、但提交记录未删,重测时仍显示旧作品 / 已提交态)。
const tx = await ctx.model.transaction();
let contestantCount = 0;
let examDeleted = 0;
let workDeleted = 0;
let zhongzhiDeleted = 0;
let gaozhiDeleted = 0;
let benkeDeleted = 0;
let jiaoshiDeleted = 0;
fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9) R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
// 🔴 C9 根因③ 修复(2026-09-29 · R45):清库只删行、不删物理文件 ⇒ 磁盘孤儿随时间累积
// (实证:`app/public/works` 已积到 1003 个 / 9.62 GB,另 `tmp` 6.6 GB)。
// 这里先在本事务内把待删记录引用的文件路径**收集起来**(只读),
// 待事务**提交成功之后**才真正删文件。
// ⚠️ 为什么不能直接在事务里删:文件删除**不可回滚**。一旦事务回滚,
// DB 记录还在、文件却已消失 ⇒ 下载 404(数据丢失,比留一个孤儿严重得多)。
// 先收集后删、且只删「行确已删除」的那批,是唯一安全的顺序。
const staleFilePaths = [];
// 只读收集器:按「模型 + 文件列 + 条件」把非空路径收集进 staleFilePaths
const collectPaths = async (Model, column, where) => {
const rows = await Model.findAll({
where,
attributes: [ column ],
raw: true,
transaction: tx,
});
for (const r of rows) {
const p = r[column];
if (p && String(p).trim()) staleFilePaths.push(String(p).trim());
}
};
try {
// 1. 重置选手状态(仅测试数据选手):恢复为「未激活 + 默认口令 + 无成绩」,
// 便于从头回归「短信激活」与完整参赛流程;同时清空成绩,避免成绩管理残留旧分。
[ contestantCount ] = await ctx.model.Contestant.update(
{ activated: false, password: defaultHash, score: null, exam_score: 0, word_score: 0, ppt_score: 0 },
{ where: { id: testIds }, transaction: tx }
);
// 同步清理关联 accounts 的成绩,避免数据随机变化
const testIdNumbers = (await ctx.model.Contestant.findAll({
where: { id: testIds },
attributes: ['id_number'],
raw: true,
transaction: tx,
})).map(c => c.id_number);
if (testIdNumbers.length) {
await ctx.model.Account.update({ score: 0 }, { where: { uid: testIdNumbers }, transaction: tx });
}
// 1.1 重置成绩发布状态为「未发布」,避免上一轮已发布状态泄漏到本轮:
// 评分保存后仅管理后台可见,必须显式「发布成绩」考生端才能看到。
// 发布状态已按组存储,必须四个分组键 + 遗留全局键一并重置,
// 只重置全局键会漏掉分组键,导致考生端仍处于「已发布」。
// (与本次重置同处一个事务:若后续任一步失败,发布态复位一并回滚,绝不泄漏。)
await service.admin.publishScores(false, undefined, tx);
fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9) R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
// 1.2 收集「即将被删除」的记录所引用的物理文件路径(必须在 DESTROY 之前,否则路径已随行消失)。
// 列名以 model 定义为准:exam/zhongzhi/gaozhi 用 `word_file_path`,
// work/benke/jiaoshi 用 `file_path`(不可跨表套用,见 verify_* 门禁的同类教训)。
await collectPaths(ctx.model.ExamSubmission, 'word_file_path', { contestant_id: testIds });
await collectPaths(ctx.model.WorkSubmission, 'file_path', { contestant_id: testIds });
await collectPaths(ctx.model.ZhongzhiSubmission, 'word_file_path', { aggregation_uid: testIds });
await collectPaths(ctx.model.GaozhiSubmission, 'word_file_path', { aggregation_uid: testIds });
// 2. 删除 exam_submissions(仅测试数据选手;客观题答案内嵌其中,一并清除)
examDeleted = await ctx.model.ExamSubmission.destroy({
where: { contestant_id: testIds },
transaction: tx,
});
// 3. 删除 work_submissions(仅测试数据选手)
workDeleted = await ctx.model.WorkSubmission.destroy({
where: { contestant_id: testIds },
transaction: tx,
});
// 4. 删除旧版组别提交记录(仅测试数据选手)
// 键位说明(易踩坑):
// - zhongzhi/gaozhi_submissions.aggregation_uid 实际存的是 contestant.id(历史实现);
// - benke/jiaoshi_submissions.aggregation_uid 存的是 aggregation.aggregation_uid,
// 须先经 aggregation 表 account_uid(=contestant.id) 解析,不能直接用 testIds,
// 否则会误删他人记录 / 漏删本人记录。
zhongzhiDeleted = await ctx.model.ZhongzhiSubmission.destroy({
where: { aggregation_uid: testIds },
transaction: tx,
});
gaozhiDeleted = await ctx.model.GaozhiSubmission.destroy({
where: { aggregation_uid: testIds },
transaction: tx,
});
const testAggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: testIds },
attributes: ['aggregation_uid'],
raw: true,
transaction: tx,
});
const testAggUids = testAggRows.map(a => a.aggregation_uid);
fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9) R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
// 1.3 补收集本科/教师组作品路径(键为聚合 uid,须在上面的 destroy 之前收集)
if (testAggUids.length) {
await collectPaths(ctx.model.BenkeSubmission, 'file_path', { aggregation_uid: testAggUids });
await collectPaths(ctx.model.JiaoshiSubmission, 'file_path', { aggregation_uid: testAggUids });
}
benkeDeleted = testAggUids.length ? await ctx.model.BenkeSubmission.destroy({
where: { aggregation_uid: testAggUids },
transaction: tx,
}) : 0;
jiaoshiDeleted = testAggUids.length ? await ctx.model.JiaoshiSubmission.destroy({
where: { aggregation_uid: testAggUids },
transaction: tx,
}) : 0;
await tx.commit();
} catch (txErr) {
await tx.rollback();
throw txErr;
}
fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9) R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
// 1.4 事务已成功提交 ⇒ 行确已删除,此时才删物理文件(C9 根因③)。
// 顺序仍是「先库后盘」:若在提交前删文件而事务回滚,会留下「记录在、文件没了」的 404。
// 删文件用 service.upload.removePublicFile —— 统一走「只有 works/、tmp/ 前缀才允许删
// + best-effort 不抛错」的安全路径;失败的文件不会静默消失,会被计数上报,
// 也可由兜底扫描 scripts/scan_orphan_files.py 捞出。
const uniqueStalePaths = [ ...new Set(staleFilePaths) ];
let orphanFilesRemoved = 0;
for (const p of uniqueStalePaths) {
if (service.upload.removePublicFile(p, 'resetTestData') === 'removed') orphanFilesRemoved++;
}
results.orphanFileCandidates = uniqueStalePaths.length;
results.orphanFilesRemoved = orphanFilesRemoved;
results.contestantsReset = contestantCount;
results.scorePublishReset = true; // 已在本事务内调用 publishScores(false),发布态已复位
results.examSubmissionsDeleted = examDeleted;
results.workSubmissionsDeleted = workDeleted;
results.zhongzhiSubmissionsDeleted = zhongzhiDeleted;
results.gaozhiSubmissionsDeleted = gaozhiDeleted;
results.benkeSubmissionsDeleted = benkeDeleted;
results.jiaoshiSubmissionsDeleted = jiaoshiDeleted;
ctx.logger.info('[测试数据重置]', results);
// R178 审计:这是本系统唯一的「真实批量删除」入口(并会连带复位发布态),
// 必须记下是谁、删了多少行 —— 事后追查「数据怎么没了」只能靠这一条。
await recordAudit(ctx, AUDIT_ACTIONS.DATA_RESET_TEST, 'test-data', {
contestants: contestantCount,
examSubmissionsDeleted: examDeleted,
workSubmissionsDeleted: workDeleted,
orphanFilesRemoved,
});
security(config): 四个同族兜底缺陷一次收口 —— APP_KEYS / 弱口令 / IPv6 兜底 / 本地生产密钥同值(R106-D6~D9) 共同根因(R104 结论的延续):**同族问题各自实现必然漂移**。 本仓 DB_PASSWORD 与 JWT_SECRET(R105-D1)已统一为「缺凭据明确失败」, 但同族的另三处仍停在「可预测的公开兜底」上,另新增一处跨环境密钥同值。 D6 · APP_KEYS 派生兜底(原 config.default.js:157) 原:const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026'; config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS; 「派生」不等于安全:由 appInfo.name 可推算 ⇒ 等价于「Cookie 签名密钥公开 ⇒ 任何人可伪造已签名 Cookie」。收口到 _secret.cjs 唯一解析入口: · scripts/_secret.cjs 抽出通用 _loadSecret(),新增 loadAppKeys() (① env → ② scripts/.creds.local.json:app_keys → ③ 明确失败,⛔ 无兜底) · 生产安全守卫移除 config.keys === DEFAULT_APP_KEYS 比对分支(已无可比兜底值) · scripts/_creds.py 与 .creds.local.example.json 同步键面(两侧同源,永不漂移) D7 · 生产弱口令兜底(原 config.default.js:176) 原:config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456'; 而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD 一直是**注释状态** ⇒ 生产 实际取到的就是弱口令 '123456'。一旦走到「批量写默认口令」的路径(重置测试数据 / 显式 testFlag=true 的导入)会批量设弱口令 —— 属**静默弱口令**而非明确失败。 修法:生产(config.isProduction)未显式注入 ⇒ 置 null;使用点改走新增的 app/lib/passwordPolicy.requireTestPassword() 明确失败。测试环境语义逐字不变。 D8 · 本地 DB 兜底 host 引发 IPv6 建连超时(原 config.default.js:217) 原:host: process.env.DB_HOST || 'localhost'。Node >= 18 的 verbatim DNS 可能把 localhost 解析到 ::1,而本机 MariaDB 只听 IPv4 ⇒ **建连 ETIMEDOUT**(不报 1045、 不报 ECONNREFUSED),现象是「后端启动卡在 DB」,极易误判为「服务没起来」。 且本仓 scripts/_db.cjs:43 的兜底早已是 '127.0.0.1' —— 同子系统两套默认值必然漂移。 修法:兜底改为 IPv4 字面量(生产显式注入 DB_HOST ⇒ 逐字不变)。 配套(config.local.js 为 gitignored 本地文件,不入库):补 host/port 覆盖 —— 「第二道保险」原本只保账号/口令,反而漏掉了真正会出问题的连接目标。 D9 · 本地 dev 与生产共用同一 JWT/APP_KEYS(loadDeployEnv) 非生产跳过清单原本只含 DB_/REDIS_/ALIYUN_/SMS_,注释称「仍保留 JWT_SECRET / APP_KEYS …… 本地无副作用」—— 该结论不成立:本地进程持有 **生产签名密钥** ⇒ 本地 token 可冒充生产用户(反之亦然),两套环境的会话边界 实际被打通,属**安全属性**缺陷。 修法:新增 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过(⛔ 不用前缀以免误伤),本地改由 scripts/.creds.local.json 供值;Python 侧 scripts/_fixtures.py resolve_jwt_secret() 同步为「非生产优先本仓凭据文件」—— 否则脚本会用生产密钥签 token 交给本地后端 验签,必然 401(正是该函数注释里记录过的 23 项级联假红)。 行为等价性(生产逐字不变,已逐项实测): · D6/D8:生产显式注入 APP_KEYS / DB_HOST ⇒ 取值路径不变 · D7:生产 resetTestData 本有 403 守卫、导入默认 testFlag=false ⇒ 原不可达 · D9:isProdEnv=true ⇒ 不跳过任何键 ⇒ 生产加载路径不变 验证: · 配置层双环境断言:本地 jwt=8066e07d5683 / keys=e4cfe40ff3ea(≠ 生产 a05591750093 / ae0d155385c1);生产两值逐字不变;生产 testDefaultPassword=null · 本地后端真实重启(仅给 DB_PORT=3307、**不给 DB_HOST**)⇒ 启动成功, /api/health {"code":0,...,"db":"ok"};日志显示跳过 16 个键(含 JWT_SECRET/APP_KEYS) · D9 判决点 d9_verify_split.py:本地密钥签 token ⇒ 200;生产密钥 ⇒ **401** · 生产部署后 r106_prod_sentinel.js:pass=11 fail=0;/api/health 200 · 门禁:verify_no_secrets.py VERIFY_ALL_PASS(HIGH 0,判别力自证 17/17); verify_script_health.py H1–H4 PASS
2026-10-12 11:06:01 +08:00
ctx.body = ctx.helper.success(results, `测试数据已重置:${contestantCount} 名选手,${examDeleted + workDeleted} 条提交记录。已恢复为「未激活 + 默认口令 ${testPw}」,需先用默认口令完成短信激活即可登录重测`);
} catch (err) {
ctx.logger.error('重置测试数据失败:', err);
ctx.body = ctx.helper.error(500, '重置测试数据失败');
}
}
feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道 ## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」 按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答 综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。 - 新增表(迁移 023/024) · `big_questions` —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径) · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分) 两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。 - 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js` - 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑) - 考生端接口(`app/controller/exam.js`) · `GET /api/exam/:group/big-question` —— 首次抽题并落库题干快照, 后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷) · `POST /api/exam/:group/big-answer` —— 保存三小问答案,带考试窗口门控 - 管理端接口(`app/controller/admin.js`) · 题库 CRUD:`/api/admin/big-questions/:group[/:id]` · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间) · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores` - 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口 一并拒绝(前端入口已移除,但直调 API 仍可绕过)。 **所有新增读写仅落在新增的两张表,不触碰任何既有表结构。** ## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口) 原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接 回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩, 而同期 `GET /api/score` 正确返回 `null`。 - `POST /api/auth/login` —— 实测未发布态回传 `score` - `GET /api/auth/current-user` —— 全字段 `toJSON`,连带下发分项 - `POST /api/auth/refresh` —— 全字段 `toJSON`,且前端 `doSilentRefresh()` 会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道; 实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段 修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛, 禁止各自直接 `toJSON`。口径: - 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认) - `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发** (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路) 门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项, 新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止 "永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。 ## 三、移除已弃用的 Word 模板 中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`) 随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。 --- 验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、 窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00
// ==================== 综合大题(中职组 / 高职组) ====================
//
// 2026-09-21 需求变更配套管理端能力:
// · 题库 CRUD(题干富文本含表格 + 三小问提示语 + 每小问 10 分 / 大题 30 分)
// · 作答查证(考生 → 抽到的题目快照 → 三小问答案 → 提交时间),用于避免考生纠纷
// · 按小问评分(合计分经既有唯一评分路径写入 contestants.word_score)
//
// 全部读写仅落在新增的 big_questions / big_question_answers,不触碰任何既有表。
/**
* 大题题库列表
* GET /api/admin/big-questions/:group
*/
async listBigQuestions() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
if (!service.bigQuestion.isEnabledGroup(group)) {
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
return;
}
const list = await service.bigQuestion.listQuestions(group);
ctx.body = ctx.helper.success({
list,
total: list.length,
scorePerSub: 10,
subCount: service.bigQuestion.subQuestionCount(),
maxChars: service.bigQuestion.answerMaxChars(),
}, '获取大题题库成功');
} catch (err) {
ctx.logger.error('获取大题题库失败:', err);
ctx.body = ctx.helper.error(500, '获取大题题库失败');
}
}
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
/**
* 导出大题为 Word(2026-09-23 需求)
* GET /api/admin/big-questions/:group/export?ids=1,2,3
* · 传 ids → 单独 / 批量导出;不传 → 导出该组全部
* · 1 道 → 返回 .docx;多道 → 返回「多个 .docx 打包的 .zip」
*
* ⚠️ 路由必须注册在 `/big-questions/:group/:id`(PUT/DELETE)之前或不同方法,
* 否则 `export` 会被当成 :id 吞掉(见 router.js 注释)。
*/
/**
* 下载综合大题 Excel 导入模板(R73)
* GET /api/admin/big-questions/:group/template
*/
async downloadBigQuestionTemplate() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const buffer = await service.bigQuestion.buildTemplate();
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
// ⚠️ 只调 ctx.attachment(原名):koa 自动做 RFC5987 编码,自行 encodeURIComponent 会双重编码
ctx.attachment(`综合大题导入模板_${group}.xlsx`);
ctx.body = buffer;
} catch (err) {
ctx.logger.error('下载大题导入模板失败:', err);
ctx.body = ctx.helper.error(500, '生成模板失败');
}
}
/**
* 综合大题 Excel 批量导入(R73)
* POST /api/admin/big-questions/:group/import-excel
* multipart/form-data: file 字段为 .xlsx/.xls
* sheet:首个 sheet 为数据表(表头在第 1 行),可选携带「填写说明」sheet
*/
async importBigQuestionsExcel() {
const { ctx, service, app } = this;
if (!this._ensureAdmin()) return;
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
let tempFilePath = null;
try {
const stream = await ctx.getFileStream();
if (!stream) {
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 tempDir = path.join(app.baseDir, 'app/public/tmp/excel');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
tempFilePath = path.join(tempDir, `big_questions_import_${Date.now()}${ext}`);
const writeStream = fs.createWriteStream(tempFilePath);
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
stream.on('error', reject);
});
const result = await service.bigQuestion.importFromExcel(tempFilePath, { group });
if (!result.ok) {
ctx.body = ctx.helper.error(400, result.message);
return;
}
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`);
} catch (err) {
ctx.logger.error('综合大题 Excel 导入失败:', err);
const msg = (err && err.message) ? err.message : '未知错误';
ctx.body = ctx.helper.error(500, '导入失败:' + msg);
} finally {
// 无论成功/失败都清临时文件:题目内容属敏感数据,不留副本
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
// 🔴 R161:finally 位置本就正确,仅把静默 unlinkSync 换成 safeCleanup(三态可观测)
if (tempFilePath) {
ctx.helper.safeCleanup(tempFilePath, { label: 'importBigQuestions-tmp' });
}
}
}
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
async exportBigQuestions() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const ids = ctx.query.ids;
const result = await service.bigQuestion.exportQuestions(group, ids);
if (!result.ok) {
const httpStatus = result.code === 'NOT_FOUND' ? 404 : 400;
ctx.body = ctx.helper.error(httpStatus, result.message);
return;
}
// ⚠️ 只调 ctx.attachment(原名):koa 会自动做 RFC5987 编码。
// 自行 encodeURIComponent 会**双重编码**,中文文件名变成乱码(同源缺陷族)。
ctx.set(
'Content-Type',
result.isZip
? 'application/zip'
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
);
ctx.attachment(result.fileName);
// 前端需要知道「本次导出了几道题」,非简单请求下无法从 body 读(body 是二进制)
ctx.set('X-Export-Count', String(result.count));
ctx.body = result.buffer;
} catch (err) {
ctx.logger.error('导出大题 Word 失败:', err);
ctx.body = ctx.helper.error(500, '导出大题 Word 失败');
}
}
/**
* 导出考生大题答卷为 Word(2026-09-28 需求:阅卷评分需将「试题 + 考生答案」导出)
* GET /api/admin/big-answers/:group/export?ids=1,2,3
* · 传 ids → 导出指定考生的答卷;不传 → 该组**全部已抽题**考生
* · 命中 1 位 → 直接返回 .docx
* · 命中多位 → 返回「每人一个独立 .docx」打包的 .zip(⛔ 不塞进同一个 Word)
*
* ⚠️ 与 `PUT /big-answers/:group/:uid/scores` 方法不同、段数不同,不会互相吞并。
* ⚠️ 只调 ctx.attachment(原名):koa 自动做 RFC5987 编码;自行 encodeURIComponent 会
* 双重编码导致中文文件名乱码(同源缺陷族,见 exportBigQuestions 注释)。
*/
async exportBigAnswers() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const result = await service.bigQuestion.exportAnswers(group, ctx.query.ids);
if (!result.ok) {
const httpStatus = result.code === 'NOT_FOUND' ? 404 : 400;
ctx.body = ctx.helper.error(httpStatus, result.message);
return;
}
ctx.set(
'Content-Type',
result.isZip
? 'application/zip'
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
);
ctx.attachment(result.fileName);
// 前端需知道本次导出了几位考生(响应体是二进制,读不到)
ctx.set('X-Export-Count', String(result.count));
ctx.body = result.buffer;
} catch (err) {
ctx.logger.error('导出考生大题答卷失败:', err);
ctx.body = ctx.helper.error(500, '导出考生大题答卷失败');
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
}
}
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
// ==================== 大题成绩 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 : '未知错误'));
}
}
feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道 ## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」 按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答 综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。 - 新增表(迁移 023/024) · `big_questions` —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径) · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分) 两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。 - 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js` - 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑) - 考生端接口(`app/controller/exam.js`) · `GET /api/exam/:group/big-question` —— 首次抽题并落库题干快照, 后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷) · `POST /api/exam/:group/big-answer` —— 保存三小问答案,带考试窗口门控 - 管理端接口(`app/controller/admin.js`) · 题库 CRUD:`/api/admin/big-questions/:group[/:id]` · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间) · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores` - 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口 一并拒绝(前端入口已移除,但直调 API 仍可绕过)。 **所有新增读写仅落在新增的两张表,不触碰任何既有表结构。** ## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口) 原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接 回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩, 而同期 `GET /api/score` 正确返回 `null`。 - `POST /api/auth/login` —— 实测未发布态回传 `score` - `GET /api/auth/current-user` —— 全字段 `toJSON`,连带下发分项 - `POST /api/auth/refresh` —— 全字段 `toJSON`,且前端 `doSilentRefresh()` 会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道; 实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段 修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛, 禁止各自直接 `toJSON`。口径: - 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认) - `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发** (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路) 门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项, 新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止 "永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。 ## 三、移除已弃用的 Word 模板 中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`) 随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。 --- 验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、 窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00
/**
* 新增大题
* POST /api/admin/big-questions/:group
* Body: { title, description, sub_prompt_1..3, score_per_sub, sort_order, status }
*/
async createBigQuestion() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const result = await service.bigQuestion.createQuestion(group, ctx.request.body || {});
if (!result.ok) {
ctx.body = ctx.helper.error(400, result.message);
return;
}
ctx.body = ctx.helper.success({
id: result.record.id,
questionUid: result.record.question_uid,
}, '大题已新增');
} catch (err) {
ctx.logger.error('新增大题失败:', err);
ctx.body = ctx.helper.error(500, '新增大题失败');
}
}
/**
* 更新大题
* PUT /api/admin/big-questions/:group/:id
*/
async updateBigQuestion() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group, id } = ctx.params;
if (!this._validateGroup(group)) return;
const qid = parseInt(id, 10);
if (isNaN(qid)) {
ctx.body = ctx.helper.error(400, '大题 ID 非法');
return;
}
const result = await service.bigQuestion.updateQuestion(group, qid, ctx.request.body || {});
if (!result.ok) {
ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 400, result.message);
return;
}
ctx.body = ctx.helper.success({ id: result.record.id }, '大题已更新');
} catch (err) {
ctx.logger.error('更新大题失败:', err);
ctx.body = ctx.helper.error(500, '更新大题失败');
}
}
/**
* 删除大题(已被考生抽取过的大题拒绝删除,引导改为「停用」)
* DELETE /api/admin/big-questions/:group/:id
*/
async deleteBigQuestion() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group, id } = ctx.params;
if (!this._validateGroup(group)) return;
const qid = parseInt(id, 10);
if (isNaN(qid)) {
ctx.body = ctx.helper.error(400, '大题 ID 非法');
return;
}
const result = await service.bigQuestion.deleteQuestion(group, qid);
if (!result.ok) {
ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 409, result.message);
return;
}
ctx.body = ctx.helper.success({ deleted: 1 }, '大题已删除');
} catch (err) {
ctx.logger.error('删除大题失败:', err);
ctx.body = ctx.helper.error(500, '删除大题失败');
}
}
/**
* 大题作答查证:按组返回「考生 → 抽到的题目(快照)→ 三小问答案 → 提交时间 → 得分」
* GET /api/admin/big-answers/:group
*
* 含该组全部考生(未抽题者 drawn=false),便于核对缺考/未作答。
* 题目取自**题干快照**而非当前题库,确保考生当时所见可举证。
*/
async getBigAnswers() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
if (!service.bigQuestion.isEnabledGroup(group)) {
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
return;
}
const list = await service.bigQuestion.listAnswersForAdmin(group);
ctx.body = ctx.helper.success({
list,
total: list.length,
drawnCount: list.filter(x => x.drawn).length,
}, '获取大题作答成功');
} catch (err) {
ctx.logger.error('获取大题作答失败:', err);
ctx.body = ctx.helper.error(500, '获取大题作答失败');
}
}
/**
* 按小问评分(三小问各 0-10,合计 0-30)
* PUT /api/admin/big-answers/:group/:uid/scores
* Body: { score_1, score_2, score_3 }
*
* 合计分经 service.admin.updateScores(既有唯一评分路径)同步 contestants.word_score /
* contestants.score / accounts,保证与「成绩发布门控」及总分公式完全一致,
* 不引入第二条写成绩的代码通路。
*/
async setBigAnswerScores() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group, uid } = ctx.params;
if (!this._validateGroup(group)) return;
const aggregationUid = parseInt(uid, 10);
if (isNaN(aggregationUid)) {
ctx.body = ctx.helper.error(400, '考生聚合 ID 非法');
return;
}
const body = ctx.request.body || {};
const result = await service.bigQuestion.setSubScores(group, aggregationUid, {
score_1: body.score_1,
score_2: body.score_2,
score_3: body.score_3,
});
if (!result.ok) {
ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 400, result.message);
return;
}
ctx.body = ctx.helper.success({
total: result.total,
perSubScore: result.perSubScore,
}, `大题评分已保存(合计 ${result.total} 分)`);
} catch (err) {
ctx.logger.error('大题评分失败:', err);
ctx.body = ctx.helper.error(500, '大题评分失败');
}
}
// ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
// ⚠️ 复核明细会回传 question.answer(正确答案),**只能**挂 admin 路由
// (若被选手端复用即为成绩泄露,见 scripts/verify_score_leak_all_channels.py)。
/**
* 检索考生
* GET /api/admin/review/search?keyword=xxx[&group=zhongzhi]
*
* keyword 命中「姓名 / 身份证号 / 电话」任一(模糊匹配)。不传 group 则全组别检索 ——
* 复核时管理员通常只拿到一个身份证号,不该被迫先猜组别。
*/
async reviewSearch() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const keyword = ctx.query.keyword;
const group = ctx.query.group || undefined;
// 传了 group 就必须合法;不传表示全组别检索。
if (group && !this._validateGroup(group, true)) return;
const result = await service.admin.reviewSearch(keyword, group);
if (!result.ok) {
ctx.body = ctx.helper.error(400, result.message);
return;
}
ctx.body = ctx.helper.success({
list: result.list,
total: result.list.length,
truncated: result.truncated,
}, '检索完成');
} catch (err) {
ctx.logger.error('复核检索失败:', err);
ctx.body = ctx.helper.error(500, '复核检索失败');
}
}
/**
* 考生全卷明细(复核)
* GET /api/admin/review/score/:group/:id
*
* :id 为 contestants.id。:group 仅用于**校验**(与考生实际组别不符则拒绝),
* 真正的取数组别以库中考生的 group 为准 —— 防止前端传错组别导致照错人出证据。
*/
async contestantScore() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group, id } = ctx.params;
if (!this._validateGroup(group, true)) return;
const cid = parseInt(id, 10);
if (isNaN(cid)) {
ctx.body = ctx.helper.error(400, '考生 ID 非法');
return;
}
const result = await service.admin.getContestantScore(cid, group);
if (!result.ok) {
// 404 资源不存在;409 资源存在但与请求上下文冲突(组别不符)
const status = result.code === 'NOT_FOUND' ? 404
: (result.code === 'GROUP_MISMATCH' ? 409 : 400);
ctx.body = ctx.helper.error(status, result.message);
return;
}
ctx.body = ctx.helper.success(result, '获取考生全卷明细成功');
} catch (err) {
ctx.logger.error('获取考生全卷明细失败:', err);
ctx.body = ctx.helper.error(500, '获取考生全卷明细失败');
}
}
feat(admin): 新增选手信息编辑接口 PUT /api/admin/contestants/:id(R108) 此前 contestants 表只有 Excel 导入一个写入口 —— 证件号/姓名/手机号录错后 改不了也删不掉。本提交新增受控编辑通道。 service.updateContestant(id, payload): · 白名单仅 name / phone / id_number;白名单外字段(group / 成绩类 / password / test_flag / team_id / activated)显式 400 拒绝,非静默忽略 (静默忽略会让管理员误以为改成功) · id_number 判据复用 lib/idCard.js(与导入通道同源):长度 != 18 拒绝; 校验位不符 / 含非法字符 => 放行 + warnings[](生产库现存正式考生中 即有 4 条校验位不符,硬拦截会造成合法数据改不动) · 唯一性冲突 => 409 · 改号连带重算口令:导入时 password = sha256(证号后 6 位),而证号即登录 账号 => 不同步重算则学生「账号对、口令错」(UI 上看不出来)。4 分支: 测试数据 / 已激活 / 已是 bcrypt => 不动 + 告警;未激活派生值 => 重算 · 同值提交(含仅大小写差异)不发 UPDATE · 审计日志证号脱敏、口令只记「是否重算」 controller.updateContestant:映射 400/404/409。 router:新增 PUT /api/admin/contestants/:id(adminAuth)。破坏面定级 = 非破坏性(白名单编辑 + 可逆 + 带审计)=> 未登记 DESTRUCTIVE_ENDPOINTS.json。 判据:scripts/verify_update_contestant.mjs(42 判据 + 4 变异自证)。 行号漂移纪律:controller/admin.js 被 3 个脚本按行号引用(:33/:1556/:1570) => 新增一律追加文件末尾,改后 3/3 未漂移。
2026-10-12 17:20:41 +08:00
// ==================== 选手信息编辑(R108 新增) ====================
/**
* 编辑已入库选手的身份字段
* PUT /api/admin/contestants/:id
*
* Body: `{ name?, phone?, id_number? }` —— 仅这三项可改,其余字段一律拒绝(400)。
* ⚠️ **可改 / 不可改的完整口径与理由**(组别为何只读、成绩字段为何禁止、改号后
* 口令如何同步)是 `service/admin.js#updateContestant` 的方法头注释 —— 那是唯一真相源,
* 此处不复制,以免两处口径漂移。
*
* 状态码映射:NOT_FOUND → 404;DUPLICATE_ID(身份证号被他人占用)→ 409;
* BAD_ID / FIELD_NOT_ALLOWED / INVALID → 400。
*/
async updateContestant() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const cid = parseInt(ctx.params.id, 10);
if (isNaN(cid)) {
ctx.body = ctx.helper.error(400, '选手 ID 非法');
return;
}
const result = await service.admin.updateContestant(cid, ctx.request.body || {});
if (!result.ok) {
const status = result.code === 'NOT_FOUND' ? 404
: (result.code === 'DUPLICATE_ID' ? 409 : 400);
ctx.body = ctx.helper.error(status, result.message);
return;
}
ctx.body = ctx.helper.success(
result,
result.changed.length === 0 ? '未检测到需要修改的内容' : '选手信息已更新'
);
} catch (err) {
ctx.logger.error('编辑选手信息失败:', err);
ctx.body = ctx.helper.error(500, '编辑选手信息失败');
}
}
}
module.exports = AdminController;