Files
competition-website/app/controller/admin.js
qubiaobiao 459f1adf6d feat(admin): R176-R180 公示表与新闻撤回 + 管理端多管理员权限(C 层)+ 交卷冻结
- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底)
  与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项
- R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据
- R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT)
  + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version)
  + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js
- R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源)
- R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段
- R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态)
- R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
2026-10-20 11:06:29 +08:00

2786 lines
103 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');
// 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/<group>/<ts>/*_chunk.pdf,实测残留 340 文件 / 4 目录 ⇒ 每次导出必泄漏)。
// 改用 safeCleanup(三态可观测,失败 logger.warn 不静默)并补删工作目录。
ctx.res.on('finish', () => {
ctx.helper.safeCleanup(zipFilePath, { label: 'batchDownload-zipFile' });
// 🔴 R161:清工作目录。zipDir 形如 `tmp/zip/<group>/<ts>/`,zipFile 形如
// `tmp/zip/<group>_submissions_<ts>.zip` ⇒ 二者**无派生关系**,不能靠去扩展名反解。
// ⛔ 不改 service 返回契约(会波及调用方);改为从 `tmp/zip` 根取出 group 后删其目录。
// 删整个 group 目录是安全的:该目录**只**存本方法的 Date.now() 工作目录,
// 不含其他业务文件(works/answers/materials 等均在 tmp 之外)。
const zipRoot = path.join(app.baseDir, 'app/public/tmp/zip');
ctx.helper.safeCleanup(path.join(zipRoot, group), { label: 'batchDownload-zipDir' });
});
} catch (err) {
ctx.logger.error('批量下载失败:', err);
// 如果 archiver 不可用,返回友好错误
if (err.code === 'MODULE_NOT_FOUND' && err.message.includes('archiver')) {
ctx.body = ctx.helper.error(500, 'ZIP 打包功能需要安装 archiver 依赖,请运行 npm install archiver');
return;
}
ctx.body = ctx.helper.error(500, '批量下载失败');
}
}
// ==================== 时间管理 ====================
/**
* 批量设置比赛时间
* 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<Buffer|null>} 失败时已写好响应体,返回 null
*/
async _readExcelBuffer(maxBytes = 2 * 1024 * 1024) {
const { ctx } = this;
let stream;
try {
stream = await ctx.getFileStream();
} catch (e) {
ctx.body = ctx.helper.error(400, '未接收到文件(需 multipart/form-data,字段名 file)');
return null;
}
const ext = path.extname(stream.filename || '').toLowerCase();
if (!['.xlsx', '.xls'].includes(ext)) {
// 必须把流消费掉,否则连接会悬挂
stream.resume();
ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式');
return null;
}
const chunks = [];
let size = 0;
try {
for await (const chunk of stream) {
size += chunk.length;
if (size > maxBytes) {
stream.resume();
ctx.body = ctx.helper.error(400, `文件过大(上限 ${Math.floor(maxBytes / 1024 / 1024)} MB)`);
return null;
}
chunks.push(chunk);
}
} catch (e) {
ctx.body = ctx.helper.error(400, '读取上传文件失败:' + (e && e.message ? e.message : '未知错误'));
return null;
}
if (!size) {
ctx.body = ctx.helper.error(400, '上传文件为空');
return null;
}
return Buffer.concat(chunks, size);
}
/**
* 下载「综合大题成绩导入」模板
* GET /api/admin/big-answers/:group/import-template
*/
async downloadBigScoreTemplate() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
if (!service.bigQuestion.isEnabledGroup(group)) {
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
return;
}
const buffer = await service.bigQuestion.buildScoreTemplate(group);
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
ctx.attachment(`综合大题成绩导入模板_${group}.xlsx`);
ctx.body = buffer;
} catch (err) {
ctx.logger.error('下载大题成绩导入模板失败:', err);
ctx.body = ctx.helper.error(500, '生成模板失败');
}
}
/**
* 预览成绩导入(⛔ 零写入)
* POST /api/admin/big-answers/:group/import-preview
* multipart/form-data: file
*/
async previewBigScoreImport() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const buffer = await this._readExcelBuffer();
if (!buffer) return;
const result = await service.bigQuestion.previewScoreImport(group, buffer);
if (!result.ok) {
ctx.body = ctx.helper.error(400, result.message);
return;
}
ctx.body = ctx.helper.success(
result,
`预览完成:可导入 ${result.importable} 行,将覆盖 ${result.overwritable} 行,被拒绝 ${result.blocked} 行`
);
} catch (err) {
ctx.logger.error('大题成绩导入预览失败:', err);
ctx.body = ctx.helper.error(500, '预览失败:' + (err && err.message ? err.message : '未知错误'));
}
}
/**
* 执行成绩导入
* POST /api/admin/big-answers/:group/import-scores?overwrite=1
* multipart/form-data: file
*/
async importBigScores() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const buffer = await this._readExcelBuffer();
if (!buffer) return;
const overwrite = ctx.query.overwrite === '1' || ctx.query.overwrite === 'true';
const result = await service.bigQuestion.importScores(group, buffer, { overwrite });
if (!result.ok) {
ctx.body = ctx.helper.error(400, result.message);
return;
}
// preview 明细过大时不必每次回传全部行;这里保留前 200 条错误供前端展示
const errors = (result.errors || []).slice(0, 200);
ctx.body = ctx.helper.success({
imported: result.imported,
skipped: result.skipped,
overwritten: result.overwritten,
errors,
errorTotal: (result.errors || []).length,
untruncated: (result.errors || []).length <= 200,
}, `导入完成:成功 ${result.imported} 行(其中覆盖 ${result.overwritten} 行),跳过 ${result.skipped} 行`);
} catch (err) {
ctx.logger.error('大题成绩导入失败:', err);
ctx.body = ctx.helper.error(500, '导入失败:' + (err && err.message ? err.message : '未知错误'));
}
}
/**
* 新增大题
* POST /api/admin/big-questions/:group
* 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;