Files
competition-website/app/service/admin.js
WorkBuddy a303cf2d53 chore: 后端首次基线提交
运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。
已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
2026-08-17 12:25:42 +08:00

1913 lines
71 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 Service = require('egg').Service;
const path = require('path');
const fs = require('fs');
const XLSX = require('xlsx');
/**
* 管理后台服务 - AdminService
*
* 封装管理后台相关的数据库操作,包括数据统计、账号管理、Excel 导入、
* 提交记录管理、CSV 导出、批量下载 ZIP、时间/评分/题目管理等。
*/
class AdminService extends Service {
// ==================== 数据统计 ====================
/**
* 获取数据总览统计
* 统计各组的账号数、提交数、总分、激活数
*
* @returns {Promise<Object>} 综合统计数据
*/
async getStats(options = {}) {
const { ctx } = this;
const groups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
// excludeTest=true 时排除测试数据(test_flag=1),仅统计生产作用域
const excludeTest = options && options.excludeTest === true;
const testWhere = excludeTest ? { test_flag: 0 } : {};
// 直接从 contestants 表统计
const totalAccounts = await ctx.model.Contestant.count({ where: testWhere });
const activatedCount = await ctx.model.Contestant.count({
where: { ...testWhere, activated: true },
});
const groupStats = {};
for (const group of groups) {
const baseWhere = { ...testWhere, group };
const totalCount = await ctx.model.Contestant.count({ where: baseWhere });
const activatedGroupCount = await ctx.model.Contestant.count({
where: { ...baseWhere, activated: true },
});
// 提交数:中职/高职查 exam_submissions,本科/教师查 work_submissions
let submittedCount = 0;
let totalScore = 0;
const contestants = await ctx.model.Contestant.findAll({
where: baseWhere,
attributes: ['id'],
});
const contestantIds = contestants.map(c => c.id);
if (contestantIds.length > 0) {
const SubmissionModel = (group === 'zhongzhi' || group === 'gaozhi')
? ctx.model.ExamSubmission
: ctx.model.WorkSubmission;
const submissions = await SubmissionModel.findAll({
where: { contestant_id: contestantIds },
attributes: ['contestant_id', 'score'],
raw: true,
});
const uniqueIds = new Set(submissions.map(s => s.contestant_id));
submittedCount = uniqueIds.size;
totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
}
groupStats[group] = {
totalAccounts: totalCount,
activatedAccounts: activatedGroupCount,
submittedCount,
totalScore,
submissionRate: totalCount > 0
? ((submittedCount / totalCount) * 100).toFixed(1)
: '0',
};
}
// 计算总提交数
let totalSubmissions = 0;
for (const group of groups) {
totalSubmissions += groupStats[group].submittedCount;
}
return {
totalAccounts,
activatedCount,
submissionsCount: totalSubmissions,
activationRate: totalAccounts > 0
? ((activatedCount / totalAccounts) * 100).toFixed(1)
: '0',
groups: groupStats,
testIncluded: !excludeTest,
};
}
// ==================== 账号管理 ====================
/**
* 获取分页账号列表
* @param {Object} options - 查询选项 { group, keyword, page, pageSize, startDate, endDate }
* @returns {Promise<Object>} { list, total, page, pageSize }
*/
async getAccounts(options = {}) {
const { ctx } = this;
const { group, keyword, startDate, endDate } = options;
const page = parseInt(options.page, 10) || 1;
const pageSize = parseInt(options.pageSize, 10) || 20;
const offset = (page - 1) * pageSize;
// 构建查询条件
const where = {};
if (group) {
where.group = group;
}
if (keyword) {
where[ctx.model.Sequelize.Op.or] = [
{ name: { [ctx.model.Sequelize.Op.like]: `%${keyword}%` } },
{ id_number: { [ctx.model.Sequelize.Op.like]: `%${keyword}%` } },
{ phone: { [ctx.model.Sequelize.Op.like]: `%${keyword}%` } },
];
}
if (startDate && endDate) {
where.created_at = {
[ctx.model.Sequelize.Op.gte]: new Date(startDate + 'T00:00:00'),
[ctx.model.Sequelize.Op.lte]: new Date(endDate + 'T23:59:59'),
};
} else if (startDate) {
where.created_at = { [ctx.model.Sequelize.Op.gte]: new Date(startDate + 'T00:00:00') };
} else if (endDate) {
where.created_at = { [ctx.model.Sequelize.Op.lte]: new Date(endDate + 'T23:59:59') };
}
const { rows, count } = await ctx.model.Contestant.findAndCountAll({
where,
attributes: { exclude: ['password'] },
order: [['id', 'DESC']],
offset,
limit: pageSize,
});
// 转换为前端期望的字段格式
const list = rows.map(row => ({
uid: row.id,
name: row.name,
name_cn: row.name,
idNumber: row.id_number,
id_number: row.id_number,
phone: row.phone,
group: row.group,
activated: row.activated,
score: row.score,
test_flag: row.test_flag,
createdAt: row.created_at,
created_at: row.created_at,
}));
return {
list,
total: count,
page: parseInt(page),
pageSize: parseInt(pageSize),
};
}
// ==================== Excel 导入 ====================
/**
* 从 Excel 文件导入账号数据
* @param {string} filePath - Excel 文件路径
* @returns {Promise<Object>} { imported, skipped, errors }
*/
async importFromExcel(filePath, options = {}) {
const { ctx } = this;
// 组别归一化:兼容中文全称(中职组/高职组/本科组/教师组)与代码(zhongzhi 等),
// 避免用户直接用原始花名册(中文组别)导入时全部被 contestants.group 的 isIn 校验拒绝、
// 静默回滚成 imported=0 却仍返回 HTTP 200「成功」的假成功。
const GROUP_ALIAS = {
'中职': 'zhongzhi', '中职组': 'zhongzhi', '中職': 'zhongzhi', '中職組': 'zhongzhi',
'高职': 'gaozhi', '高职组': 'gaozhi', '高職': 'gaozhi', '高職組': 'gaozhi',
'本科': 'benke', '本科组': 'benke', '本科組': 'benke',
'教师': 'jiaoshi', '教师组': 'jiaoshi', '教師': 'jiaoshi', '教師組': 'jiaoshi',
};
const normalizeGroup = (g) => {
if (!g) return '';
const s = String(g).trim();
return GROUP_ALIAS[s] || s;
};
// 是否将本批次标记为测试数据(test_flag=1)。
// 优先使用显式入参;未传时回退到运行模式:测试环境导入默认归类为测试数据,
// 生产环境导入默认归类为生产数据(差异最小化,调用方无需区分环境)。
const importAsTest = (options && typeof options.testFlag === 'boolean')
? options.testFlag
: ctx.app.config.isTest;
// 测试数据统一使用「默认口令」的 bcrypt 哈希,便于重置/导入后直接登录重测。
let defaultTestHash = null;
if (importAsTest) {
defaultTestHash = await ctx.helper.hashPassword(ctx.app.config.testDefaultPassword);
}
// 读取 Excel 文件
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
// 转换为 JSON 数组
const rows = XLSX.utils.sheet_to_json(sheet);
const result = {
imported: 0,
skipped: 0,
teamsCreated: 0,
errors: [],
};
// 取一个可用的竞赛 ID 用于聚合报名记录(aggregation.competition_uid 非空约束)
let competitionUid = 1;
try {
const comp = await ctx.model.CompetitionInfo.findOne({
attributes: ['competition_uid'],
order: [['competition_uid', 'DESC']],
});
if (comp && comp.competition_uid) competitionUid = comp.competition_uid;
} catch (e) {
ctx.logger.warn('[import] 获取竞赛 ID 失败,使用默认 1:', e.message);
}
// 第一遍:解析并预建/复用团队(在任意行事务之外,确保已提交、全局可见)。
// 团队归属是整数 team_uid,是 G1 团队考试锁在生产数据下生效的前提;
// 必须在行事务开启前完成,否则 Egg 的 CLS 会把团队查/建绑到当前行事务快照,
// 导致同一导入的后续行读不到上一行已提交的团队,把同一队伍拆成多个团队。
// 注意:teams.team_uid 在模型/迁移中已声明为「由应用层显式赋值、非 DB 自增」
// (运行库真实自增主键为 id,team_uid 为普通 INT 列)。故此处显式生成唯一 team_uid,
// 不依赖数据库自增,保证 contestants.team_id 与 teams.team_uid 一致、G1 锁可靠。
const teamUidCache = {};
let maxTeamUid = 0;
try {
const row = await ctx.model.Team.findOne({
attributes: [[ctx.model.Sequelize.fn('MAX', ctx.model.Sequelize.col('team_uid')), 'm']],
});
if (row && row.get('m')) maxTeamUid = parseInt(row.get('m'), 10) || 0;
} catch (e) { /* ignore */ }
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const teamIdRaw = String(row['队伍编号'] || row['team_id'] || row['team_number'] || '').trim();
const group = normalizeGroup(row['组别'] || row['group']);
if (!teamIdRaw) continue;
const cacheKey = teamIdRaw + '|' + group;
if (teamUidCache[cacheKey] !== undefined) continue;
let team = await ctx.model.Team.findOne({ where: { team_id: teamIdRaw, group_name: group } });
// 健壮性:命中 team_uid 为空的损坏占位行时,销毁并重建,避免脏数据污染
if (team && !team.team_uid) {
try { await team.destroy(); } catch (e2) { /* ignore */ }
team = null;
}
if (!team) {
maxTeamUid += 1;
team = await ctx.model.Team.create({
team_uid: maxTeamUid,
group_name: group,
team_id: teamIdRaw,
team_number: teamIdRaw,
});
result.teamsCreated++;
}
teamUidCache[cacheKey] = maxTeamUid;
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowIndex = i + 2;
// 整行事务:队伍 + 选手 + 聚合记录要么一起成功,要么整体回滚
const t = await ctx.model.transaction();
try {
const idNumber = String(row['身份证号'] || row['id_number'] || '').trim();
const name = String(row['姓名'] || row['name'] || '').trim();
const phone = String(row['手机号'] || row['phone'] || '').trim();
const group = normalizeGroup(row['组别'] || row['group']);
// 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁)
const teamIdRaw = String(row['队伍编号'] || row['team_id'] || row['team_number'] || '').trim();
// 可选:身份(队长/队员),默认队员
const identity = String(row['身份'] || row['identity'] || '').trim() || '队员';
if (!idNumber) {
result.errors.push({ row: rowIndex, message: '身份证号为空' });
result.skipped++;
await t.rollback();
continue;
}
if (!name) {
result.errors.push({ row: rowIndex, message: '姓名为空' });
result.skipped++;
await t.rollback();
continue;
}
// 检查身份证号是否已存在
const existing = await ctx.model.Contestant.findOne({
where: { id_number: idNumber },
transaction: t,
});
if (existing) {
result.errors.push({ row: rowIndex, message: `身份证号 ${idNumber} 已存在` });
result.skipped++;
await t.rollback();
continue;
}
// 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提)
let teamUid = null;
if (teamIdRaw) {
teamUid = teamUidCache[teamIdRaw + '|' + group];
}
// 生成密码:测试数据统一使用「默认口令」的 bcrypt 哈希;生产数据沿用
// 「身份证后6位 SHA256」派生(保持原行为,不暴露统一弱口令)。
const password = importAsTest
? defaultTestHash
: ctx.helper.sha256(idNumber.substring(idNumber.length - 6));
// 创建选手记录(team_id 为团队整数 uid;单人参赛则为 NULL)
const contestant = await ctx.model.Contestant.create({
id_number: idNumber,
name,
phone: phone || null,
group: group || 'zhongzhi',
password,
activated: false,
score: 0,
team_id: teamUid,
test_flag: importAsTest ? 1 : 0,
}, { transaction: t });
// 建立聚合报名记录(与生产上传/作品流程对齐:benke/jiaoshi 作品上传依赖 aggregation)。
// 始终为每位导入选手建立(team_uid 可空:单人参赛也能建聚合,避免作品上传 403)。
const aggExists = await ctx.model.Aggregation.findOne({
where: { account_uid: contestant.id },
transaction: t,
});
if (!aggExists) {
await ctx.model.Aggregation.create({
competition_uid: competitionUid,
team_uid: teamUid,
account_uid: contestant.id,
identity,
is_registrant: true,
}, { transaction: t });
}
await t.commit();
result.imported++;
} catch (err) {
await t.rollback();
ctx.logger.error(`导入第 ${rowIndex} 行失败:`, err);
result.errors.push({ row: rowIndex, message: err.message });
result.skipped++;
}
}
return result;
}
// ==================== 提交记录管理 ====================
/**
* 获取考试答题记录(查询组别对应的提交表)
* @param {string} group - 组别
* @returns {Promise<Array>} 答题记录
*/
async getExamSubmissions(group) {
const { ctx } = this;
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score'],
order: [['id', 'ASC']],
});
// 建立 contestant.id -> 真实 aggregation_uid 映射。
// 旧代码曾把聚合报名 uid 直接写进 zhongzhi/gaozhi_submissions 的 aggregation_uid 列,
// 而当前 _getUserAggregation 返回 aggregation_uid = contestant.id;两种写法并存于历史数据。
// 若仅按 contestant.id 归集,历史提交者的答题数/提交状态会因聚合键错位而恒为 0 / 未提交。
// 故按「contestant.id」与「真实 aggregation_uid」两种 key 同时归集,消除错位。
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: contestants.map(c => c.id) },
attributes: ['account_uid', 'aggregation_uid'],
raw: true,
});
const realAggByContestant = {};
aggRows.forEach(a => { realAggByContestant[a.account_uid] = a.aggregation_uid; });
// 获取所有提交记录
const allSubmissions = await SubmissionModel.findAll({
order: [['created_at', 'ASC']],
});
// 按 contestant.id 分组提交记录
const submissionsByUser = {};
allSubmissions.forEach(s => {
const uid = String(s.aggregation_uid);
if (!submissionsByUser[uid]) submissionsByUser[uid] = [];
submissionsByUser[uid].push(s);
});
// 中职/高职的 Word 操作文件由 uploadWord 写入 exam_submissions.word_file_path,
// 需单独查询以补全 hasWordFile / wordFilePath(逐题作答表 zhongzhi_submissions/
// gaozhi_submissions 本身不存储 Word 文件信息)。
const examSubs = await ctx.model.ExamSubmission.findAll({
where: { contestant_id: contestants.map(c => c.id) },
attributes: ['contestant_id', 'word_file_path', 'word_file_name', 'submit_time'],
raw: true,
});
const wordMap = {};
examSubs.forEach(w => { wordMap[String(w.contestant_id)] = w; });
return contestants.map(c => {
// 兼容新旧两种聚合键:同时归集 contestant.id 与真实 aggregation_uid 下的答题记录
const candidateKeys = [String(c.id), String(realAggByContestant[c.id])].filter(Boolean);
const subs = candidateKeys.flatMap(k => submissionsByUser[k] || []);
const answeredCount = subs.length;
const autoScore = subs.reduce((sum, s) => sum + (s.score || 0), 0);
const wordSub = wordMap[String(c.id)];
const hasWord = !!(wordSub && wordSub.word_file_path);
// 正式提交状态:优先以 exam_submissions.submit_time 为准(与选手端 hasSubmitted 同一判定源);
// 历史数据/旧流程未回填 submit_time,但已有逐题答题记录(answeredCount>0)同样视为已提交,
// 与 CSV 导出(answeredCount>0 -> 是)保持一致,避免「试卷管理恒显未提交」的错位。
const formalSubmitTime = (wordSub && wordSub.submit_time) || null;
return {
contestantId: c.id,
aggregationUid: String(c.id),
userId: String(c.id),
userName: c.name || '',
idNumber: c.id_number,
phone: c.phone,
group: c.group,
answeredCount,
totalQuestions: answeredCount,
autoScore,
examScore: c.exam_score ?? 0,
wordScore: c.word_score ?? 0,
// 总分 = 客观题自动评分合计 + Word 阅卷分(与卷面分/Word 分口径一致);
// 不读 contestants.score(未人工评分时恒为 0,会导致"客观题有分但总分=0")。
totalScore: autoScore + (c.word_score ?? 0),
hasWordFile: hasWord,
wordFilePath: (wordSub && wordSub.word_file_path) || null,
wordFileName: (wordSub && wordSub.word_file_name) || null,
submitted: !!formalSubmitTime || answeredCount > 0,
submitTime: formalSubmitTime || (subs.length > 0 ? subs[0].created_at : null),
// 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致
examScore: autoScore,
};
});
}
/**
* 获取提交记录(作品提交等)
* @param {string} group - 组别 (zhongzhi/gaozhi/benke/jiaoshi)
* @param {Object} options - 查询选项
*/
async getSubmissions(group, options = {}) {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 获取该组所有选手(含成绩分项)
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'],
order: [['id', 'ASC']],
});
// 获取所有提交记录(benke_submissions.aggregation_uid 存的是"聚合报名ID",
// 与 contestants.id 并非同一序列,需经 aggregation 表 account_uid 映射)
const allSubmissions = await SubmissionModel.findAll({
order: [['upload_time', 'DESC']],
});
const submissionMap = {};
allSubmissions.forEach(s => {
const key = `${s.aggregation_uid}__${s.file_type || 'word'}`;
if (!submissionMap[key]) submissionMap[key] = s;
});
// 建立 contestant.id -> aggregation_uid 映射(修复"作品管理看不到上传"的错位)
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: contestants.map(c => c.id) },
attributes: ['account_uid', 'aggregation_uid'],
raw: true,
});
const aggMap = {};
aggRows.forEach(a => { aggMap[a.account_uid] = a.aggregation_uid; });
const toItem = (s) => s ? {
id: s.id,
file_name: s.file_name,
file_size: s.file_size,
file_path: s.file_path,
file_md5: s.file_md5,
upload_time: s.upload_time,
score: s.score,
file_type: s.file_type || 'word',
} : null;
return contestants.map(c => {
const aggUid = aggMap[c.id] || c.id;
const wordSub = submissionMap[`${aggUid}__word`] || null;
const pptSub = submissionMap[`${aggUid}__ppt`] || null;
const sub = wordSub || pptSub;
return {
contestantId: c.id,
account: {
uid: c.id,
name_cn: c.name,
id_number: c.id_number,
phone: c.phone,
organization_cn: '',
score: c.score,
exam_score: c.exam_score,
word_score: c.word_score,
ppt_score: c.ppt_score,
},
team: { team_id: '', team_number: '', group_name: c.group },
identity: '选手',
submitted: !!sub,
wordSubmission: toItem(wordSub),
pptSubmission: toItem(pptSub),
workSubmissions: [toItem(wordSub), toItem(pptSub)].filter(Boolean),
};
});
}
/**
* 获取作品管理统一列表(覆盖全部 4 个组别)
* - 本科/教师组:benke_submissions / jiaoshi_submissions 的 word + ppt 文件
* - 中职/高职组:exam_submissions 中的 Word 答题答案文件
* 返回归一化结构,便于前端统一渲染、在线预览、打分、批量下载。
* @param {string} group
* @returns {Promise<Array>} [{ contestantId, name, idNumber, phone, group, files: [{ id, kind, fileName, filePath, fileSize, score, uploadedAt, scoreField }] }]
*/
async getWorks(group) {
const { ctx } = this;
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'word_score', 'ppt_score', 'exam_score'],
order: [['id', 'ASC']],
raw: true,
});
if (!isExam) {
const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission;
const rows = await SubmissionModel.findAll({ order: [['upload_time', 'DESC']], raw: true });
// benke/jiaoshi_submissions.aggregation_uid 对应 aggregation.aggregation_uid(非 contestant.id),
// 需经 aggregation 表 account_uid(= contestant.id)映射回选手
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: contestants.map(c => c.id) },
attributes: ['account_uid', 'aggregation_uid'],
raw: true,
});
// 正向映射:contestant.id(=account_uid) → aggregation_uid,
// 再用 aggregation_uid 取该选手的提交记录(方向不可颠倒!)
const contestantToAgg = {};
aggRows.forEach(a => { contestantToAgg[a.account_uid] = a.aggregation_uid; });
const byAgg = {};
rows.forEach(r => { (byAgg[r.aggregation_uid] = byAgg[r.aggregation_uid] || []).push(r); });
return contestants.map(c => {
const subs = byAgg[contestantToAgg[c.id]] || [];
const files = subs.map(s => ({
id: s.id,
kind: s.file_type || 'word',
fileName: s.file_name,
filePath: s.file_path,
fileSize: s.file_size,
score: s.score != null ? Number(s.score) : null,
uploadedAt: s.upload_time,
scoreField: s.file_type === 'ppt' ? 'ppt' : 'word',
}));
return { contestantId: c.id, name: c.name, idNumber: c.id_number, phone: c.phone, group: c.group, files };
});
}
// 中职/高职组:Word 答题答案存于 exam_submissions
const examSubs = await ctx.model.ExamSubmission.findAll({
where: { contestant_id: contestants.map(c => c.id) },
attributes: ['id', 'contestant_id', 'word_file_path', 'word_file_name'],
raw: true,
});
const wordMap = {};
examSubs.forEach(w => { if (w.word_file_path) wordMap[w.contestant_id] = w; });
return contestants.map(c => {
const w = wordMap[c.id];
// ⚠️ kind==='answer' 的下载端点是 /api/admin/submissions/:group/:id/word,
// 后端按 contestant_id 查询,此处 id 必须传 contestant_id 而非 exam_submissions.id,
// 否则 ID 错位会下载到他人文件或 404 JSON(存成损坏 docx 的根因)。
const files = w ? [{
id: w.contestant_id,
kind: 'answer',
fileName: w.word_file_name,
filePath: w.word_file_path,
fileSize: null,
score: c.word_score != null ? Number(c.word_score) : null,
uploadedAt: null,
scoreField: 'word',
}] : [];
return { contestantId: c.id, name: c.name, idNumber: c.id_number, phone: c.phone, group: c.group, files };
});
}
// ==================== CSV 导出 ====================
/**
* 导出某组提交记录为 CSV 字符串
* @param {string} group - 组别
* @returns {Promise<string>} CSV 内容
*/
async exportToCsv(group) {
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const submissions = isExam
? await this.getExamSubmissions(group)
: await this.getSubmissions(group);
let csvContent = '';
if (isExam) {
// 中职/高职组:客观题(系统) + Word(人工)
csvContent = '选手ID,身份证号,姓名,手机号,是否提交,客观题分,Word分,总分\n';
for (const item of submissions) {
csvContent += [
item.contestantId,
`"${item.idNumber || ''}"`,
`"${item.userName || ''}"`,
`"${item.phone || ''}"`,
item.answeredCount > 0 ? '是' : '否',
item.examScore ?? 0,
item.wordScore ?? 0,
item.totalScore ?? 0,
].join(',') + '\n';
}
} else {
// 本科/教师组:Word(人工) + PPT(人工)
csvContent = '选手ID,身份证号,姓名,手机号,是否提交,文档文件名,文档大小,文档分,PPT文件名,PPT大小,PPT分\n';
for (const item of submissions) {
const account = item.account || {};
const wordSub = item.wordSubmission;
const pptSub = item.pptSubmission;
csvContent += [
item.contestantId,
`"${account.id_number || ''}"`,
`"${account.name_cn || ''}"`,
`"${account.phone || ''}"`,
item.submitted ? '是' : '否',
`"${wordSub ? wordSub.file_name : ''}"`,
wordSub ? wordSub.file_size : '',
wordSub && wordSub.score != null ? wordSub.score : '',
`"${pptSub ? pptSub.file_name : ''}"`,
pptSub ? pptSub.file_size : '',
pptSub && pptSub.score != null ? pptSub.score : '',
].join(',') + '\n';
}
}
return csvContent;
}
// ==================== 批量下载 ZIP ====================
/**
* 批量下载某组所有提交文件,打包为 ZIP
* 文件重命名为 "组号-学校-姓名-原文件名" 格式
*
* @param {string} group - 组别
* @returns {Promise<string>} ZIP 文件路径
*/
async batchDownloadZip(group) {
const { ctx, app } = this;
// 获取该组的所有提交记录(考试组与作品组数据形状不同)
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const submissions = isExam
? await this.getExamSubmissions(group)
: await this.getSubmissions(group);
// 构建临时 ZIP 目录
const zipDir = path.join(app.baseDir, 'app/public/tmp/zip', group, String(Date.now()));
if (!fs.existsSync(zipDir)) {
fs.mkdirSync(zipDir, { recursive: true });
}
// 收集所有需要打包的文件
const filesToZip = [];
// 本科/教师组需要 account.name_cn;考试组使用 getExamSubmissions 的 userName
for (const item of submissions) {
const account = item.account || {};
const userName = (isExam ? (item.userName || '未知') : (account.name_cn || '未知')).replace(/[\\/:*?"<>|]/g, '_');
if (isExam) {
// 中职/高职组:收集 Word 答案文件
if (item.wordFilePath) {
const fullPath = path.join(app.baseDir, 'app/public', item.wordFilePath);
if (fs.existsSync(fullPath)) {
const origName = item.wordFileName || path.basename(fullPath);
const targetName = `${userName}-${origName}`;
const targetPath = path.join(zipDir, targetName);
fs.copyFileSync(fullPath, targetPath);
filesToZip.push({ srcPath: fullPath, zipName: targetName });
}
}
} else {
// 本科/教师组:收集 Word + PPT 作品文件
for (const sub of (item.workSubmissions || [])) {
if (sub.file_path) {
const fullPath = path.join(app.baseDir, 'app/public', sub.file_path);
if (fs.existsSync(fullPath)) {
const origName = sub.file_name || path.basename(fullPath);
const targetName = `${userName}-${sub.file_type || 'file'}-${origName}`;
const targetPath = path.join(zipDir, targetName);
fs.copyFileSync(fullPath, targetPath);
filesToZip.push({ srcPath: fullPath, zipName: targetName });
}
}
}
}
}
// 创建 ZIP 文件
const zipFileName = `${group}_submissions_${Date.now()}.zip`;
const zipFilePath = path.join(app.baseDir, 'app/public/tmp/zip', zipFileName);
// 使用 archiver 将收集到的文件打包为 ZIP
// archiver v8 为 ESM,导出 ZipArchive 等类;用 new ZipArchive(...) 实例化
const { ZipArchive } = require('archiver');
const output = fs.createWriteStream(zipFilePath);
const archive = new ZipArchive({ zlib: { level: 9 } });
await new Promise((resolve, reject) => {
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
// 将重命名后的文件添加到 ZIP
const copiedFiles = fs.readdirSync(zipDir);
for (const file of copiedFiles) {
const filePath = path.join(zipDir, file);
archive.file(filePath, { name: file });
}
archive.finalize();
});
// 🔴 清理临时目录:best-effort(同源缺陷族,与 controller/upload.js:mergeChunks 同修)。
// ZIP 已 finalize 落盘,清理失败绝不能把成功的批量下载判成 500
// (common-error.log 实证:/api/admin/batch-download/* 曾因此整体失败)。
this.ctx.helper.safeCleanup(zipDir, { label: 'batchZip-tmp' });
return zipFilePath;
}
// ==================== 评分管理 ====================
/**
* 批量更新评分
* @param {Array} scores - 评分数组 [{ submissionId, score }]
* @param {string} group - 组别
*/
async updateScores(scores, group) {
const { ctx } = this;
for (const item of scores) {
const { contestantId, examScore, wordScore, pptScore } = item;
if (!contestantId) continue;
const contestant = await ctx.model.Contestant.findByPk(contestantId);
if (!contestant) continue;
// P2-2 事务补全:单个选手的评分涉及 contestants + accounts + 作品提交表
// 多表写入,包裹在同一事务中,任一步失败整体回滚,避免"改了总分、
// 没同步分项"的中间状态脏数据。
await ctx.model.transaction(async (t) => {
if (group === 'zhongzhi' || group === 'gaozhi') {
// 中职/高职组:客观题(系统评分)+ Word(人工评分)
let examS;
if (examScore !== undefined && examScore !== null && examScore !== '') {
examS = Number(examScore);
} else {
// 未显式传客观题分则按逐题分数自动求和
examS = await this._computeObjectiveScore(contestantId, group);
}
const wordS = (wordScore !== undefined && wordScore !== null && wordScore !== '')
? Number(wordScore)
: (contestant.word_score || 0);
await contestant.update({
exam_score: examS,
word_score: wordS,
score: examS + wordS,
}, { transaction: t });
} else {
// 本科/教师组:Word(人工评分)+ PPT(人工评分)
const wordS = (wordScore !== undefined && wordScore !== null && wordScore !== '')
? Number(wordScore)
: (contestant.word_score || 0);
const pptS = (pptScore !== undefined && pptScore !== null && pptScore !== '')
? Number(pptScore)
: (contestant.ppt_score || 0);
await contestant.update({
word_score: wordS,
ppt_score: pptS,
score: wordS + pptS,
}, { transaction: t });
// 历史兼容:同步 accounts.score
const aggregation = await ctx.model.Aggregation.findOne({
where: { account_uid: contestantId },
transaction: t,
});
if (aggregation && aggregation.account_uid) {
await ctx.model.Account.update(
{ score: wordS + pptS },
{ where: { uid: aggregation.account_uid }, transaction: t }
);
}
}
// 将分项分数同步到作品提交记录(按文件查看分项)
await this._syncSubmissionScores(contestantId, group, {
wordS: contestant.word_score || 0,
pptS: contestant.ppt_score || 0,
}, t);
});
}
}
/**
* 计算选手客观题自动评分总和(中职/高职组)
* @param {number} contestantId - 选手 ID
* @param {string} group - 组别
* @returns {Promise<number>}
*/
async _computeObjectiveScore(contestantId, group) {
const { ctx } = this;
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const rows = await SubmissionModel.findAll({
where: { aggregation_uid: contestantId },
attributes: ['score'],
});
return rows.reduce((sum, r) => sum + (r.score || 0), 0);
}
/**
* 将分项分数同步到作品提交记录(Word / PPT 各自一行)
* @param {number} contestantId - 选手 ID
* @param {string} group - 组别
* @param {Object} scores - { wordS, pptS }
* @param {Object} [transaction] - Sequelize 事务(P2-2:随 updateScores 事务一起提交/回滚)
*/
async _syncSubmissionScores(contestantId, group, { wordS, pptS }, transaction = null) {
const { ctx } = this;
// ⚠️ 仅本科/教师组有作品提交表(benke/jiaoshi_submissions)需要同步分项。
// 中职/高职组分数存于 contestants + 逐题表,若误走 JiaoshiSubmission 会把
// 教师组同 aggregation_uid 的作品分数改错(跨组数据污染,评分不同步 Bug 之一)。
if (group !== 'benke' && group !== 'jiaoshi') return;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// benke_submissions.aggregation_uid 是聚合报名ID,需用 account_uid 解析
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: contestantId },
transaction,
});
const aggUid = agg ? agg.aggregation_uid : contestantId;
const wordSub = await SubmissionModel.findOne({
where: { aggregation_uid: aggUid, file_type: 'word' },
transaction,
});
if (wordSub) await wordSub.update({ score: wordS }, { transaction });
const pptSub = await SubmissionModel.findOne({
where: { aggregation_uid: aggUid, file_type: 'ppt' },
transaction,
});
if (pptSub) await pptSub.update({ score: pptS }, { transaction });
}
/**
* 更新账号的总分(汇总所有提交记录的得分)
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
*/
async _updateAccountTotalScore(aggregationUid, group) {
const { ctx } = this;
let totalScore = 0;
if (group === 'zhongzhi' || group === 'gaozhi') {
// 中职/高职组:aggregation_uid 即考生 ID,直接从 contestants 表更新
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const submissions = await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
attributes: ['score'],
});
totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
await ctx.model.Contestant.update(
{ score: totalScore },
{ where: { id: aggregationUid } }
);
} else {
// 本/教组:aggregation_uid 对应 aggregation 表主键
const aggregation = await ctx.model.Aggregation.findByPk(aggregationUid);
if (!aggregation) return;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const submissions = await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
attributes: ['score'],
});
totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
await ctx.model.Account.update(
{ score: totalScore },
{ where: { uid: aggregation.account_uid } }
);
}
}
// ==================== 题目管理 ====================
/**
* 根据题型获取对应的模型
* @param {string} questionType - 题型:choice / judge / word
* @returns {Object} Sequelize 模型
*/
_getQuestionModel(questionType) {
const { ctx } = this;
switch (questionType) {
case 'choice': return ctx.model.ChoiceQuestion;
case 'judge': return ctx.model.JudgeQuestion;
case 'word': return ctx.model.WordQuestion;
default: throw new Error(`未知题型: ${questionType}`);
}
}
/**
* 管理考试题目(替换操作:先清空再创建)
* @param {Array} questions - 题目数组
* @param {string} group - 组别
* @param {string} questionType - 题型:choice / judge / word
*/
async manageQuestions(questions, group, questionType) {
const { ctx } = this;
const QuestionModel = this._getQuestionModel(questionType);
const transaction = await ctx.model.transaction();
try {
// 清空该组该题型的所有题目
await QuestionModel.destroy({
where: { group },
transaction,
truncate: false,
});
// 批量插入新题目
const questionData = questions.map((q, index) => {
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据,整批替换时直接拒绝
const scoreNum = Number(q.score);
if (!isFinite(scoreNum) || scoreNum <= 0) {
throw new Error(`题目分值(score)必须为大于 0 的合法数值(第 ${index + 1} 题)`);
}
const base = {
group,
score: scoreNum,
sort_order: q.sort_order !== undefined ? q.sort_order : index + 1,
};
if (questionType === 'choice') {
return {
...base,
question: q.question || '',
options: q.options || [],
answer: q.answer || '',
};
} else if (questionType === 'judge') {
return {
...base,
question: q.question || '',
answer: q.answer || 'false',
};
} else {
// word
return {
...base,
title: q.title || q.question || '',
description: q.description || '',
template_path: q.template_path || null,
};
}
});
if (questionData.length > 0) {
await QuestionModel.bulkCreate(questionData, { transaction });
}
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
}
/**
* 添加单道题目
* @param {Object} questionData - 题目数据
* @param {string} group - 组别
* @param {string} questionType - 题型:choice / judge / word
* @returns {Promise<Object>} 创建的题目
*/
async addQuestion(questionData, group, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据
// (抽中该题的考生答对也得 0 分,且不同人满分不一致)。缺失/非法分值直接拒绝。
const scoreNum = Number(questionData.score);
if (!isFinite(scoreNum) || scoreNum <= 0) {
throw new Error('题目分值(score)必须为大于 0 的合法数值');
}
const data = {
group,
score: scoreNum,
sort_order: questionData.sort_order || 0,
};
if (questionType === 'choice') {
data.question = questionData.question || '';
data.options = questionData.options || [];
data.answer = questionData.answer || '';
} else if (questionType === 'judge') {
data.question = questionData.question || '';
data.answer = questionData.answer || 'false';
} else {
data.title = questionData.title || questionData.question || '';
data.description = questionData.description || '';
data.template_path = questionData.template_path || null;
}
return await QuestionModel.create(data);
}
/**
* 更新单道题目
* @param {number} questionId - 题目 ID
* @param {Object} questionData - 更新数据
* @param {string} questionType - 题型:choice / judge / word
*/
async updateQuestion(questionId, questionData, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
const question = await QuestionModel.findByPk(questionId);
if (!question) {
throw new Error('题目不存在');
}
const updateData = {
score: questionData.score !== undefined ? questionData.score : question.score,
sort_order: questionData.sort_order !== undefined ? questionData.sort_order : question.sort_order,
};
if (questionType === 'choice') {
updateData.question = questionData.question !== undefined ? questionData.question : question.question;
updateData.options = questionData.options !== undefined ? questionData.options : question.options;
updateData.answer = questionData.answer !== undefined ? questionData.answer : question.answer;
} else if (questionType === 'judge') {
updateData.question = questionData.question !== undefined ? questionData.question : question.question;
updateData.answer = questionData.answer !== undefined ? questionData.answer : question.answer;
} else {
updateData.title = questionData.title !== undefined ? questionData.title : question.title;
updateData.description = questionData.description !== undefined ? questionData.description : question.description;
updateData.template_path = questionData.template_path !== undefined ? questionData.template_path : question.template_path;
}
await question.update(updateData);
return question;
}
/**
* 删除单道题目
* @param {number} questionId - 题目 ID
* @param {string} questionType - 题型:choice / judge / word
*/
async deleteQuestion(questionId, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
await QuestionModel.destroy({ where: { id: questionId } });
}
/**
* 批量删除题目(按题型)
* @param {number[]} ids - 题目 ID 数组
* @param {string} questionType - choice / judge / word
* @returns {Promise<number>} 删除行数
*/
async bulkDeleteQuestions(ids, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
return await QuestionModel.destroy({ where: { id: ids } });
}
// ==================== 时间管理 ====================
/**
* 批量设置比赛时间
* @param {Array} times - 时间配置数组 [{ group, startTime, endTime, deadline }]
*/
async setTimes(times) {
const { ctx } = this;
for (const timeConfig of times) {
const { group, startTime, endTime, deadline } = timeConfig;
// 客观题抽取数量:缺省 50/20;数量不足时考试抽题会按题库实际数量自动封顶
const choiceCount = timeConfig.choiceCount != null ? parseInt(timeConfig.choiceCount, 10) : 50;
const judgeCount = timeConfig.judgeCount != null ? parseInt(timeConfig.judgeCount, 10) : 20;
// 时间线统一铁律:管理界面「考试结束时间/提交截止时间」均绑定 end_time;
// 本科/教师组的 deadline 列强制与 end_time 同步,防止旧 deadline 残值
// 导致「状态显示进行中、上传却提示已截止」的不一致。
const isUploadGroup = group === 'benke' || group === 'jiaoshi';
const effectiveDeadline = isUploadGroup
? (endTime ? new Date(endTime) : null)
: (deadline ? new Date(deadline) : null);
await ctx.model.CompetitionTime.upsert({
group: group,
start_time: startTime ? new Date(startTime) : null,
end_time: endTime ? new Date(endTime) : null,
deadline: effectiveDeadline,
choice_count: choiceCount,
judge_count: judgeCount,
});
}
}
// ==================== 时间查询 ====================
/**
* 获取所有组的比赛时间配置
* @returns {Promise<Array>} 时间配置列表
*/
async getTimes() {
const { ctx } = this;
const times = await ctx.model.CompetitionTime.findAll({
attributes: ['group', 'start_time', 'end_time', 'deadline', 'word_template', 'choice_count', 'judge_count'],
order: [['group', 'ASC']],
raw: true,
});
// 附带各组题库实际题目数,便于后台在「配置抽取数量」时直观看到「题库实际 / 配置抽取」
const fmtLocal = (d) => {
if (!d) return null;
const dt = new Date(d);
const p = (n) => String(n).padStart(2, '0');
return `${dt.getFullYear()}-${p(dt.getMonth() + 1)}-${p(dt.getDate())}T${p(dt.getHours())}:${p(dt.getMinutes())}:${p(dt.getSeconds())}`;
};
const result = [];
for (const t of times) {
const [availableChoice, availableJudge] = await Promise.all([
ctx.model.ChoiceQuestion.count({ where: { group: t.group } }),
ctx.model.JudgeQuestion.count({ where: { group: t.group } }),
]);
result.push({
group: t.group,
startTime: fmtLocal(t.start_time),
endTime: fmtLocal(t.end_time),
deadline: fmtLocal(t.deadline),
wordTemplate: t.word_template || null,
choiceCount: t.choice_count != null ? t.choice_count : 50,
judgeCount: t.judge_count != null ? t.judge_count : 20,
availableChoice,
availableJudge,
});
}
return result;
}
// ==================== 评分查询 ====================
/**
* 获取某组评分列表
* @param {string} group - 组别
* @returns {Promise<Array>} 评分列表
*/
async getScores(group) {
const { ctx } = this;
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'],
order: [['id', 'ASC']],
raw: true,
});
// contestant.id -> 真实 aggregation_uid 映射。
// 历史数据曾把真实聚合 uid 写进 zhongzhi/gaozhi_submissions 的 aggregation_uid 列,
// 而当前 _getUserAggregation 返回 aggregation_uid = contestant.id;两种写法并存。
// 若仅按 contestant.id 归集,纯历史数据者的答题数/卷面分会因聚合键错位而恒为 0。
// 故按「contestant.id」与「真实 aggregation_uid」两种 key 同时归集,消除错位。
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: contestants.map(c => c.id) },
attributes: ['account_uid', 'aggregation_uid'],
raw: true,
});
const realAggByContestant = {};
aggRows.forEach(a => { realAggByContestant[a.account_uid] = a.aggregation_uid; });
const list = contestants.map(c => ({
uid: c.id,
name: c.name,
idNumber: c.id_number,
group: c.group,
score: c.score || 0,
examScore: c.exam_score ?? 0,
wordScore: c.word_score ?? 0,
pptScore: c.ppt_score ?? 0,
}));
if (isExam) {
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const ans = await SubmissionModel.findAll({
attributes: ['aggregation_uid', 'score'],
raw: true,
});
// 按「真实聚合 uid」归集:count=答题数,score=客观题自动评分合计(卷面分)
const statMap = {};
ans.forEach(s => {
const k = String(s.aggregation_uid);
if (!statMap[k]) statMap[k] = { count: 0, score: 0 };
statMap[k].count += 1;
statMap[k].score += (s.score || 0);
});
const examSubs = await ctx.model.ExamSubmission.findAll({
where: { contestant_id: contestants.map(c => c.id) },
attributes: ['contestant_id', 'word_file_path', 'submit_time', 'created_at'],
raw: true,
});
const examMap = {};
examSubs.forEach(w => { examMap[String(w.contestant_id)] = w; });
list.forEach(item => {
const keys = [String(item.uid), String(realAggByContestant[item.uid])].filter(Boolean);
let answeredCount = 0;
let autoScore = 0;
keys.forEach(k => {
const m = statMap[k];
if (m) { answeredCount += m.count; autoScore += m.score; }
});
item.answeredCount = answeredCount;
// 卷面分 = 客观题自动评分合计(不依赖人工评分列 exam_score,避免未评分时恒为 0)
item.examScore = autoScore;
// 总分 = 卷面分 + Word 阅卷分(不读 contestants.score,避免未人工评分时恒为 0)
item.score = autoScore + item.wordScore;
const es = examMap[String(item.uid)];
item.hasWordFile = !!(es && es.word_file_path);
item.submitted = !!(es && es.submit_time) || answeredCount > 0;
item.submitTime = (es && es.submit_time)
|| (answeredCount > 0 ? (es && es.created_at) || null : null);
});
} else {
// 本科/教师组:上传作品即视为已提交(与考生端 exam.status.submitted、作品管理口径一致)。
// 🔴 聚合键铁律:benke/jiaoshi_submissions.aggregation_uid = aggregation 表真实聚合 uid
//(≠ contestant.id),须按「contestant.id + 真实 aggregation_uid」双向映射归集,
// 否则真实选手(如郑佳怡 cid=11/agg=1)恒显示未提交。
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const rows = await SubmissionModel.findAll({
attributes: ['aggregation_uid', 'upload_time'],
order: [['upload_time', 'ASC']],
raw: true,
});
const subByAgg = {};
rows.forEach(s => {
const k = String(s.aggregation_uid);
if (!subByAgg[k]) subByAgg[k] = s;
});
list.forEach(item => {
const keys = [String(item.uid), String(realAggByContestant[item.uid])].filter(Boolean);
let sub = null;
keys.forEach(k => { if (!sub && subByAgg[k]) sub = subByAgg[k]; });
item.answeredCount = 0;
item.hasWordFile = false;
item.submitted = !!sub;
item.submitTime = sub ? sub.upload_time : null;
// 总分 = Word 阅卷分 + PPT 阅卷分(与分项口径一致,不读 contestants.score)
item.score = item.wordScore + item.pptScore;
});
}
return list;
}
// ==================== 题目查询 ====================
/**
* 获取某组考试题目列表(按题型分表返回)
* @param {string} group - 组别
* @returns {Promise<Object>} { choice: [...], judge: [...], word: [...] }
*/
async getQuestions(group) {
const { ctx } = this;
const queryOptions = { where: { group }, order: [['sort_order', 'ASC']], raw: true };
const [choiceQuestions, judgeQuestions, wordQuestions] = await Promise.all([
ctx.model.ChoiceQuestion.findAll(queryOptions),
ctx.model.JudgeQuestion.findAll(queryOptions),
ctx.model.WordQuestion.findAll(queryOptions),
]);
return {
choice: choiceQuestions.map(q => ({
id: q.id,
question_type: 'choice',
question: q.question,
options: typeof q.options === 'string' ? JSON.parse(q.options) : q.options,
answer: q.answer,
score: q.score,
sort_order: q.sort_order,
})),
judge: judgeQuestions.map(q => ({
id: q.id,
question_type: 'judge',
question: q.question,
answer: q.answer,
score: q.score,
sort_order: q.sort_order,
})),
word: wordQuestions.map(q => ({
id: q.id,
question_type: 'word',
title: q.title,
description: q.description,
score: q.score,
sort_order: q.sort_order,
template_path: q.template_path,
})),
};
}
/**
* 批量按题型设置分值:将该组该题型全部题目统一设为指定分值
* @param {string} group - 组别
* @param {string} questionType - choice / judge / word
* @param {number} score - 目标分值
* @returns {Promise<number>} 受影响行数
*/
async setTypeScore(group, questionType, score) {
const { ctx } = this;
const QuestionModel = this._getQuestionModel(questionType);
const [updated] = await QuestionModel.update(
{ score },
{ where: { group } }
);
return updated;
}
/**
* 客观题 Excel 批量导入(单工作表:题型 / 题目 / 选项A~D / 正确答案 / 分值)
* @param {string} filePath - 临时 Excel 文件路径
* @param {Object} options - { group }
* @returns {Promise<{imported:number, skipped:number, errors:Array}>}
*/
async importQuestionsFromExcel(filePath, options = {}) {
const { ctx } = this;
const group = options.group;
const workbook = XLSX.readFile(filePath);
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawRows = XLSX.utils.sheet_to_json(sheet);
// 表头归一化(Excel 导入 0 成功 Bug 修复):
// 测试人员的 Excel 表头常带前后空格 / 全角空格(如「题型 」「 题目」),
// sheet_to_json 的键名严格匹配会导致 row['题型'] 全部 undefined → 整表被
// 「题型无效」跳过。此处将每行键名 trim(含全角空格)后重建,同时兼容常见别名。
const keyAlias = {
'题目类型': '题型', '类型': '题型',
'题干': '题目', '题目内容': '题目',
'答案': '正确答案', '标准答案': '正确答案',
'分数': '分值', '题目分值': '分值',
'A': '选项A', 'B': '选项B', 'C': '选项C', 'D': '选项D',
};
const rows = rawRows.map(raw => {
const clean = {};
for (const k of Object.keys(raw)) {
let nk = String(k).replace(/[\s\u3000\uFEFF]+/g, '');
if (keyAlias[nk]) nk = keyAlias[nk];
clean[nk] = raw[k];
}
return clean;
});
const result = { imported: 0, skipped: 0, errors: [] };
// 先按题型分桶,最后用 bulkCreate 一次性入库,减少逐条事务开销
const buckets = { choice: [], judge: [] };
const optionKeys = ['选项A', '选项B', '选项C', '选项D'];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowIndex = i + 2; // Excel 行号(含表头)
const rawType = String(row['题型'] || '').trim();
let qType = null;
if (['选择题', 'choice'].includes(rawType)) qType = 'choice';
else if (['判断题', 'judge'].includes(rawType)) qType = 'judge';
if (!qType) {
result.errors.push({ row: rowIndex, message: `题型无效:${rawType || '(空)'}` });
result.skipped++;
continue;
}
const question = String(row['题目'] || '').trim();
if (!question) {
result.errors.push({ row: rowIndex, message: '题目内容为空' });
result.skipped++;
continue;
}
let answer = String(row['正确答案'] || '').trim();
let options = null;
if (qType === 'choice') {
options = optionKeys
.map((k, idx) => ({ key: String.fromCharCode(65 + idx), text: String(row[k] || '').trim() }))
.filter(o => o.text);
if (options.length < 2) {
result.errors.push({ row: rowIndex, message: '选择题至少需要 2 个非空选项' });
result.skipped++;
continue;
}
if (!answer) {
result.errors.push({ row: rowIndex, message: '选择题正确答案(选项字母)为空' });
result.skipped++;
continue;
}
// 校验答案字母均落在已填选项内(兼容多选如 AB)
const validKeys = options.map(o => o.key);
const ansKeys = answer.toUpperCase().split('');
if (!ansKeys.every(k => validKeys.includes(k))) {
result.errors.push({ row: rowIndex, message: `正确答案 ${answer} 超出已填选项 ${validKeys.join('')}` });
result.skipped++;
continue;
}
} else {
// 判断题答案归一化:对/正确/true/t/√ -> true;错/错误/false/f/× -> false
const a = answer.toLowerCase();
if (['对', '正确', 'true', 't', '√', '是', 'y', 'yes'].includes(a)) answer = 'true';
else if (['错', '错误', 'false', 'f', '×', '否', 'n', 'no'].includes(a)) answer = 'false';
if (answer !== 'true' && answer !== 'false') {
result.errors.push({ row: rowIndex, message: `判断题正确答案无效:${answer || '(空)'}` });
result.skipped++;
continue;
}
}
const scoreRaw = row['分值'];
let score = parseInt(scoreRaw, 10);
if (isNaN(score) || score < 1) score = 5;
const data = { group, question, answer, score, sort_order: 0 };
if (qType === 'choice') data.options = options;
buckets[qType].push(data);
}
// 一次性批量入库(各自在独立事务中,避免单条失败拖垮整批)
for (const qType of ['choice', 'judge']) {
const list = buckets[qType];
if (!list.length) continue;
const QuestionModel = this._getQuestionModel(qType);
const created = await QuestionModel.bulkCreate(list);
result.imported += created.length;
}
return result;
}
/**
* 生成客观题 Excel 导入模板(含表头 + 1 行示例)
* @returns {Promise<Buffer>} xlsx 文件 Buffer
*/
async buildQuestionTemplate() {
const header = ['题型', '题目', '选项A', '选项B', '选项C', '选项D', '正确答案', '分值'];
const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 5];
const ws = XLSX.utils.aoa_to_sheet([header, example]);
// 判断题示例行(另起一行,便于参考)
XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 5]], { origin: -1 });
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '客观题');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
// ==================== 新闻查询 ====================
/**
* 获取所有新闻列表(管理员视图,包含未发布)
* @returns {Promise<Array>} 新闻列表
*/
async listNews() {
const { ctx } = this;
const news = await ctx.model.News.findAll({
order: [['pinned', 'DESC'], ['sort_order', 'ASC'], ['publish_time', 'DESC']],
raw: true,
});
return news.map(n => ({
id: n.id,
title: n.title,
content: n.content,
news_type: n.news_type,
wx_link: n.wx_link,
publish_time: n.publish_time,
pinned: n.pinned,
group: n.group,
tags: n.tags,
sort_order: n.sort_order,
created_at: n.created_at,
updated_at: n.updated_at,
}));
}
// ==================== 轮播图查询 ====================
/**
* 获取所有轮播图列表
* @returns {Promise<Array>} 轮播图列表
*/
async listCarousel() {
const { ctx } = this;
const carousels = await ctx.model.Carousel.findAll({
order: [['sort_order', 'ASC']],
raw: true,
});
return carousels.map(c => ({
id: c.id,
image_url: c.image_url,
title: c.title || '',
content: c.content || '',
button_text: c.button_text || '',
button_link: c.button_link || '',
sort_order: c.sort_order,
link_url: c.link_url,
enabled: c.enabled,
created_at: c.created_at,
updated_at: c.updated_at,
}));
}
// ==================== Word 模板管理 ====================
/**
* 上传 Word 模板文件并更新配置
* @param {string} group - 组别
* @param {Object} fileInfo - 文件信息 { filePath }
*/
async uploadWordTemplate(group, fileInfo) {
const { ctx } = this;
await ctx.model.CompetitionTime.upsert({
group: group,
word_template: fileInfo.filePath,
});
}
// ==================== 成绩发布管理 ====================
/**
* 成绩发布状态按组独立存储。
*
* 历史实现只有一个全局键 `score_published`,而管理后台「发布成绩」按钮是按组呈现的
* (scoresPublishedMap[group])——发布任意一组会让四个组的考生同时看到成绩,
* 这正是「中职/高职未发布却能看到成绩」的直接成因之一。
* 现改为每组一个键 `score_published_<group>`(仅新增 system_configs 行,无表结构改动)。
*
* 注意:**不回退读取遗留全局键**。遗留库中该键自建库起即为 'true',
* 一旦作为兜底就会把缺陷重新引回(详见 setScorePublishKey 的还原说明)。
*/
static get PUBLISH_GROUPS() {
return [ 'zhongzhi', 'gaozhi', 'benke', 'jiaoshi' ];
}
/**
* 组别 → 配置键名
* @param {string} group - 组别
* @returns {string} 配置键
*/
_publishKey(group) {
return `score_published_${group}`;
}
/**
* 写入单个配置键(不存在则创建)
* @param {string} key - 配置键
* @param {string} value - 配置值
*/
async _setConfig(key, value, transaction) {
const { ctx } = this;
const opts = transaction ? { transaction } : {};
const [ config, created ] = await ctx.model.SystemConfig.findOrCreate({
where: { config_key: key },
defaults: { config_key: key, config_value: value },
...opts,
});
if (!created && config.config_value !== value) {
await config.update({ config_value: value }, opts);
}
}
/**
* 发布/撤销成绩
* @param {boolean} publish - 是否发布
* @param {string} [group] - 组别;缺省表示对全部组生效(兼容旧调用方)
*/
async publishScores(publish, group, transaction) {
const value = publish ? 'true' : 'false';
const groups = group
? [ group ]
: this.constructor.PUBLISH_GROUPS;
for (const g of groups) {
await this._setConfig(this._publishKey(g), value, transaction);
}
// 同步维护遗留全局键,仅用于兼容仍在读取它的外部工具;
// 业务判定一律以分组键为准。整体发布/撤销时才更新它。
if (!group) {
await this._setConfig('score_published', value, transaction);
}
}
/**
* 检查成绩是否已发布
* @param {string} [group] - 组别;缺省时要求「全部组均已发布」才返回 true
* @returns {Promise<boolean>} 严格布尔值
*/
async isScorePublished(group) {
const { ctx } = this;
if (group) {
const config = await ctx.model.SystemConfig.findOne({
where: { config_key: this._publishKey(group) },
});
// 键缺失 = 未发布(安全默认)。必须返回严格布尔,
// 否则 `config && ...` 在无行时得到 null,前端 `=== true` 判定与断言都会失真。
return !!(config && config.config_value === 'true');
}
const statusMap = await this.getScorePublishMap();
return this.constructor.PUBLISH_GROUPS.every(g => statusMap[g] === true);
}
/**
* 获取各组成绩发布状态
* @returns {Promise<Object>} { zhongzhi: bool, gaozhi: bool, benke: bool, jiaoshi: bool }
*/
async getScorePublishMap() {
const { ctx } = this;
const groups = this.constructor.PUBLISH_GROUPS;
const rows = await ctx.model.SystemConfig.findAll({
where: { config_key: groups.map(g => this._publishKey(g)) },
attributes: [ 'config_key', 'config_value' ],
raw: true,
});
const byKey = {};
rows.forEach(r => { byKey[r.config_key] = r.config_value; });
const result = {};
groups.forEach(g => {
result[g] = byKey[this._publishKey(g)] === 'true';
});
return result;
}
// ==================== 新闻管理 ====================
/**
* 创建新闻
* @param {Object} newsData - 新闻数据
* @returns {Promise<Object>}
*/
async createNews(newsData) {
const { ctx } = this;
return await ctx.model.News.create({
title: newsData.title,
content: newsData.news_type === 'wx_link' ? (newsData.content || null) : (newsData.content || ''),
wx_link: newsData.news_type === 'wx_link' ? (newsData.wx_link || null) : null,
news_type: newsData.news_type || 'content',
publish_time: newsData.publish_time || new Date(),
pinned: newsData.pinned || false,
group: newsData.group || newsData.group_name || 'all',
tags: newsData.tags || null,
});
}
/**
* 更新新闻
* @param {number} id - 新闻 ID
* @param {Object} newsData - 更新数据
*/
async updateNews(id, newsData) {
const { ctx } = this;
const news = await ctx.model.News.findByPk(id);
if (!news) throw new Error('新闻不存在');
await news.update({
title: newsData.title !== undefined ? newsData.title : news.title,
content: newsData.content !== undefined ? newsData.content : news.content,
wx_link: newsData.wx_link !== undefined ? newsData.wx_link : news.wx_link,
news_type: newsData.news_type !== undefined ? newsData.news_type : news.news_type,
publish_time: newsData.publish_time !== undefined ? newsData.publish_time : news.publish_time,
pinned: newsData.pinned !== undefined ? newsData.pinned : news.pinned,
group: newsData.group !== undefined ? newsData.group : (newsData.group_name !== undefined ? newsData.group_name : news.group),
tags: newsData.tags !== undefined ? newsData.tags : news.tags,
});
return news;
}
/**
* 删除新闻
* @param {number} id - 新闻 ID
*/
async deleteNews(id) {
const { ctx } = this;
await ctx.model.News.destroy({ where: { id } });
}
/**
* 获取新闻列表(管理端)
* @param {Object} query - 查询参数
* @returns {Promise<Object>}
*/
async listNews(query) {
const { ctx } = this;
const { page = 1, pageSize = 20, group, pinned } = query || {};
const where = {};
if (group && group !== 'all') {
where.group = group;
}
if (pinned !== undefined) {
where.pinned = pinned;
}
const { count, rows } = await ctx.model.News.findAndCountAll({
where,
order: [['pinned', 'DESC'], ['publish_time', 'DESC']],
offset: (page - 1) * pageSize,
limit: parseInt(pageSize),
raw: true,
});
return { list: rows, total: count, page: parseInt(page), pageSize: parseInt(pageSize) };
}
/**
* 获取新闻详情(管理端)
* @param {number} id - 新闻 ID
* @returns {Promise<Object>}
*/
async getNewsDetail(id) {
const { ctx } = this;
const news = await ctx.model.News.findByPk(id);
if (!news) throw new Error('新闻不存在');
return news;
}
/**
* 添加轮播图
* @param {Object} carouselData - 轮播图数据
* @returns {Promise<Object>}
*/
async addCarousel(carouselData) {
const { ctx } = this;
return await ctx.model.Carousel.create({
image_url: carouselData.image_url || null,
title: carouselData.title || null,
content: carouselData.content || null,
button_text: carouselData.button_text || null,
button_link: carouselData.button_link || null,
sort_order: carouselData.sort_order || 0,
link_url: carouselData.link_url || null,
enabled: carouselData.enabled !== false,
});
}
/**
* 更新轮播图
* @param {number} id - 轮播图 ID
* @param {Object} carouselData - 更新数据
*/
async updateCarousel(id, carouselData) {
const { ctx } = this;
const carousel = await ctx.model.Carousel.findByPk(id);
if (!carousel) throw new Error('轮播图不存在');
await carousel.update({
image_url: carouselData.image_url !== undefined ? carouselData.image_url : carousel.image_url,
title: carouselData.title !== undefined ? carouselData.title : carousel.title,
content: carouselData.content !== undefined ? carouselData.content : carousel.content,
button_text: carouselData.button_text !== undefined ? carouselData.button_text : carousel.button_text,
button_link: carouselData.button_link !== undefined ? carouselData.button_link : carousel.button_link,
sort_order: carouselData.sort_order !== undefined ? carouselData.sort_order : carousel.sort_order,
link_url: carouselData.link_url !== undefined ? carouselData.link_url : carousel.link_url,
enabled: carouselData.enabled !== undefined ? carouselData.enabled : carousel.enabled,
});
return carousel;
}
/**
* 删除轮播图(同时删除关联的图片文件)
* @param {number} id - 轮播图 ID
*/
async deleteCarousel(id) {
const { ctx } = this;
const carousel = await ctx.model.Carousel.findByPk(id);
if (!carousel) throw new Error('轮播图不存在');
// 删除关联的图片文件
if (carousel.image_url) {
const path = require('path');
const fs = require('fs');
const app = this.app;
const filename = carousel.image_url.replace(/^\/public\/carousel\//, '');
if (filename) {
const filePath = path.join(app.baseDir, 'app/public/carousel', filename);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (fileErr) {
ctx.logger.warn('删除轮播图图片文件失败:', fileErr.message);
}
}
}
await ctx.model.Carousel.destroy({ where: { id } });
}
// ==================== 成绩查询 ====================
// ==================== 成绩查询 ====================
/**
* 获取选手个人成绩详情
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @returns {Promise<Object>} 成绩详情
*/
async getContestantScore(aggregationUid, group) {
const { ctx } = this;
const result = {};
if (group === 'zhongzhi' || group === 'gaozhi') {
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const submissions = await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
attributes: ['id', 'question_uid', 'answer_data', 'is_correct', 'score', 'word_file_path'],
});
const totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
result.score = totalScore;
result.totalQuestions = submissions.length;
result.correctCount = submissions.filter(s => s.is_correct).length;
result.details = submissions;
result.hasWordFile = submissions.some(s => s.word_file_path);
} else {
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const submissions = await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
attributes: ['id', 'work_name', 'file_name', 'file_path', 'file_size', 'upload_time', 'score'],
order: [['upload_time', 'DESC']],
});
result.score = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
result.submissions = submissions;
}
return result;
}
}
module.exports = AdminService;