'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'); // 🔴 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 { // ⚠️ 筛选条件必须**整体透传**给 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; // 🔴 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 }); // 🔴 R161:清理已移至外层 finally(此处不再单独清理,避免重复与漏清)。 // 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败 const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : ''; // 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); } 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() { 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 在流发完后触发) // 🔴 R161:原实现只删 zipFilePath,**漏删 batchDownloadZip 建的 zipDir 工作目录** // (tmp/zip///*_chunk.pdf,实测残留 340 文件 / 4 目录 ⇒ 每次导出必泄漏)。 // 改用 safeCleanup(三态可观测,失败 logger.warn 不静默)并补删工作目录。 ctx.res.on('finish', () => { ctx.helper.safeCleanup(zipFilePath, { label: 'batchDownload-zipFile' }); // 🔴 R161:清工作目录。zipDir 形如 `tmp/zip///`,zipFile 形如 // `tmp/zip/_submissions_.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, '批量下载失败'); } } // ==================== 时间管理 ==================== /** * 批量设置比赛时间 * 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; } } await service.admin.setTimes(times); ctx.body = ctx.helper.success(null, '比赛时间设置成功'); } catch (err) { 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, '获取比赛时间失败'); } } /** * 节点备份调度器状态 * 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) { // 客观题(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') { // 分值口径已统一(2026-09-23):客观题(choice/judge)恒为 1 分, // 传入的 score 被忽略;仅综合性任务(word)按传入值设置(service 层校验)。 const updated = await service.admin.setTypeScore(group, qType, score); const typeLabel = qType === 'choice' ? '选择题' : (qType === 'judge' ? '判断题' : '综合性任务'); 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; // 🔴 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 }); 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); } 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。) // 🔴 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; // 🔴 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); // 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); // 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; } // 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, }); ctx.body = ctx.helper.success(results, `测试数据已重置:${contestantCount} 名选手,${examDeleted + workDeleted} 条提交记录。已恢复为「未激活 + 默认口令 ${testPw}」,需先用默认口令完成短信激活即可登录重测`); } catch (err) { ctx.logger.error('重置测试数据失败:', err); ctx.body = ctx.helper.error(500, '重置测试数据失败'); } } // ==================== 综合大题(中职组 / 高职组) ==================== // // 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, '获取大题题库失败'); } } /** * 导出大题为 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 { // 无论成功/失败都清临时文件:题目内容属敏感数据,不留副本 // 🔴 R161:finally 位置本就正确,仅把静默 unlinkSync 换成 safeCleanup(三态可观测) if (tempFilePath) { ctx.helper.safeCleanup(tempFilePath, { label: 'importBigQuestions-tmp' }); } } } 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, '导出考生大题答卷失败'); } } // ==================== 大题成绩 Excel 导入(R172) ==================== // // 流程:下载模板 → 上传并**预览**(零写入)→ 确认后**导入**(写入走唯一评分路径)。 // 两段式是刻意的:成绩误写不可撤销,先让管理员看到每一行的判定结果再落库。 /** * 把上传的 Excel 读进内存 Buffer(⛔ 不落盘)。 * * 为什么不落盘: * ① 成绩表含考生成绩(敏感),既有题目导入落在 `app/public/tmp/excel`, * 而 `app/public` 由 egg-static 对外暴露 ⇒ 文件名可猜即可下载; * ② 不落盘就不存在「finally 忘了清理」留下的孤儿文件(R161 那类问题的根因)。 * 体积上限 2MB 足够(成绩表通常几十行)。 * * @param {number} maxBytes * @returns {Promise} 失败时已写好响应体,返回 null */ async _readExcelBuffer(maxBytes = 2 * 1024 * 1024) { const { ctx } = this; let stream; try { stream = await ctx.getFileStream(); } catch (e) { ctx.body = ctx.helper.error(400, '未接收到文件(需 multipart/form-data,字段名 file)'); return null; } const ext = path.extname(stream.filename || '').toLowerCase(); if (!['.xlsx', '.xls'].includes(ext)) { // 必须把流消费掉,否则连接会悬挂 stream.resume(); ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式'); return null; } const chunks = []; let size = 0; try { for await (const chunk of stream) { size += chunk.length; if (size > maxBytes) { stream.resume(); ctx.body = ctx.helper.error(400, `文件过大(上限 ${Math.floor(maxBytes / 1024 / 1024)} MB)`); return null; } chunks.push(chunk); } } catch (e) { ctx.body = ctx.helper.error(400, '读取上传文件失败:' + (e && e.message ? e.message : '未知错误')); return null; } if (!size) { ctx.body = ctx.helper.error(400, '上传文件为空'); return null; } return Buffer.concat(chunks, size); } /** * 下载「综合大题成绩导入」模板 * GET /api/admin/big-answers/:group/import-template */ async downloadBigScoreTemplate() { const { ctx, service } = this; if (!this._ensureAdmin()) return; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; if (!service.bigQuestion.isEnabledGroup(group)) { ctx.body = ctx.helper.error(400, '该组别未启用综合大题'); return; } const buffer = await service.bigQuestion.buildScoreTemplate(group); ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); ctx.attachment(`综合大题成绩导入模板_${group}.xlsx`); ctx.body = buffer; } catch (err) { ctx.logger.error('下载大题成绩导入模板失败:', err); ctx.body = ctx.helper.error(500, '生成模板失败'); } } /** * 预览成绩导入(⛔ 零写入) * POST /api/admin/big-answers/:group/import-preview * multipart/form-data: file */ async previewBigScoreImport() { const { ctx, service } = this; if (!this._ensureAdmin()) return; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; const buffer = await this._readExcelBuffer(); if (!buffer) return; const result = await service.bigQuestion.previewScoreImport(group, buffer); if (!result.ok) { ctx.body = ctx.helper.error(400, result.message); return; } ctx.body = ctx.helper.success( result, `预览完成:可导入 ${result.importable} 行,将覆盖 ${result.overwritable} 行,被拒绝 ${result.blocked} 行` ); } catch (err) { ctx.logger.error('大题成绩导入预览失败:', err); ctx.body = ctx.helper.error(500, '预览失败:' + (err && err.message ? err.message : '未知错误')); } } /** * 执行成绩导入 * POST /api/admin/big-answers/:group/import-scores?overwrite=1 * multipart/form-data: file */ async importBigScores() { const { ctx, service } = this; if (!this._ensureAdmin()) return; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; const buffer = await this._readExcelBuffer(); if (!buffer) return; const overwrite = ctx.query.overwrite === '1' || ctx.query.overwrite === 'true'; const result = await service.bigQuestion.importScores(group, buffer, { overwrite }); if (!result.ok) { ctx.body = ctx.helper.error(400, result.message); return; } // preview 明细过大时不必每次回传全部行;这里保留前 200 条错误供前端展示 const errors = (result.errors || []).slice(0, 200); ctx.body = ctx.helper.success({ imported: result.imported, skipped: result.skipped, overwritten: result.overwritten, errors, errorTotal: (result.errors || []).length, untruncated: (result.errors || []).length <= 200, }, `导入完成:成功 ${result.imported} 行(其中覆盖 ${result.overwritten} 行),跳过 ${result.skipped} 行`); } catch (err) { ctx.logger.error('大题成绩导入失败:', err); ctx.body = ctx.helper.error(500, '导入失败:' + (err && err.message ? err.message : '未知错误')); } } /** * 新增大题 * POST /api/admin/big-questions/:group * 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, '获取考生全卷明细失败'); } } // ==================== 选手信息编辑(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;