Files
competition-website/app/controller/admin.js
qubiaobiao 31da9580e5 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

2111 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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');
/**
* 管理控制器 - 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 {
const { group, keyword, page = 1, pageSize = 20 } = ctx.query;
const result = await service.admin.getAccounts({ group, keyword, page, pageSize });
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;
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 });
// 清理临时文件
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
// 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败
const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : '';
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条${ignoredTip}`);
} catch (err) {
ctx.logger.error('Excel 导入失败:', err);
const msg = (err && err.message) ? err.message : '未知错误';
// 注意:前端 onExcelImport 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀
ctx.body = ctx.helper.error(500, msg);
}
}
// ==================== 提交记录管理 ====================
/**
* 获取某组的提交记录
* 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 } = 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 在流发完后触发)
ctx.res.on('finish', () => {
try { fs.unlinkSync(zipFilePath); } catch (e) { /* ignore */ }
});
} 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, '获取比赛时间失败');
}
}
// ==================== 评分管理 ====================
/**
* 批量设置评分
* 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;
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, `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 });
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`);
} catch (err) {
ctx.logger.error('客观题 Excel 导入失败:', err);
const msg = (err && err.message) ? err.message : '未知错误';
// 前端 onExcelFileChange 已统一加「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀
ctx.body = ctx.helper.error(500, msg);
}
}
/**
* 下载客观题 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);
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, '上传新闻图片失败');
}
}
/**
* 下载考生答案 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。)
const defaultHash = await ctx.helper.hashPassword(ctx.app.config.testDefaultPassword);
// 事务包裹全部写操作:任意一步失败整体回滚,避免「部分重置」脏状态
// (例如选手状态已清零、但提交记录未删,重测时仍显示旧作品 / 已提交态)。
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);
ctx.body = ctx.helper.success(results, `测试数据已重置:${contestantCount} 名选手,${examDeleted + workDeleted} 条提交记录。已恢复为「未激活 + 默认口令 ${ctx.app.config.testDefaultPassword}」,需先用默认口令完成短信激活即可登录重测`);
} 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 注释)。
*/
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, '导出考生大题答卷失败');
}
}
/**
* 新增大题
* 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, '获取考生全卷明细失败');
}
}
}
module.exports = AdminController;