2026-08-17 12:25:42 +08:00
|
|
|
|
'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 */ }
|
|
|
|
|
|
|
|
|
|
|
|
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`);
|
|
|
|
|
|
} 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 失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 批量下载某组所有提交文件为 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) {
|
|
|
|
|
|
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据
|
|
|
|
|
|
if (q.score == null || !isFinite(Number(q.score)) || Number(q.score) <= 0) {
|
|
|
|
|
|
ctx.body = ctx.helper.error(400, '题目分值(score)必须为大于 0 的合法数值');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
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') {
|
|
|
|
|
|
// 批量按题型设置分值:将该组该题型全部题目统一设为指定分值
|
|
|
|
|
|
if (score == null || isNaN(parseInt(score, 10))) {
|
|
|
|
|
|
ctx.body = ctx.helper.error(400, '批量设分需要合法的 score 数值');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const updated = await service.admin.setTypeScore(group, qType, parseInt(score, 10));
|
|
|
|
|
|
const typeLabel = qType === 'choice' ? '选择题' : (qType === 'judge' ? '判断题' : '综合性任务');
|
|
|
|
|
|
ctx.body = ctx.helper.success({ updated }, `已将${typeLabel}统一设为 ${score} 分`);
|
|
|
|
|
|
} 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;
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 11:57:58 +08:00
|
|
|
|
|
|
|
|
|
|
// ==================== 综合大题(中职组 / 高职组) ====================
|
|
|
|
|
|
//
|
|
|
|
|
|
// 2026-09-21 需求变更配套管理端能力:
|
|
|
|
|
|
// · 题库 CRUD(题干富文本含表格 + 三小问提示语 + 每小问 10 分 / 大题 30 分)
|
|
|
|
|
|
// · 作答查证(考生 → 抽到的题目快照 → 三小问答案 → 提交时间),用于避免考生纠纷
|
|
|
|
|
|
// · 按小问评分(合计分经既有唯一评分路径写入 contestants.word_score)
|
|
|
|
|
|
//
|
|
|
|
|
|
// 全部读写仅落在新增的 big_questions / big_question_answers,不触碰任何既有表。
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 大题题库列表
|
|
|
|
|
|
* GET /api/admin/big-questions/:group
|
|
|
|
|
|
*/
|
|
|
|
|
|
async listBigQuestions() {
|
|
|
|
|
|
const { ctx, service } = this;
|
|
|
|
|
|
if (!this._ensureAdmin()) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { group } = ctx.params;
|
|
|
|
|
|
if (!this._validateGroup(group)) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!service.bigQuestion.isEnabledGroup(group)) {
|
|
|
|
|
|
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const list = await service.bigQuestion.listQuestions(group);
|
|
|
|
|
|
ctx.body = ctx.helper.success({
|
|
|
|
|
|
list,
|
|
|
|
|
|
total: list.length,
|
|
|
|
|
|
scorePerSub: 10,
|
|
|
|
|
|
subCount: service.bigQuestion.subQuestionCount(),
|
|
|
|
|
|
maxChars: service.bigQuestion.answerMaxChars(),
|
|
|
|
|
|
}, '获取大题题库成功');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
ctx.logger.error('获取大题题库失败:', err);
|
|
|
|
|
|
ctx.body = ctx.helper.error(500, '获取大题题库失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 新增大题
|
|
|
|
|
|
* 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, '大题评分失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-17 12:25:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = AdminController;
|