运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。 已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
1913 lines
71 KiB
JavaScript
1913 lines
71 KiB
JavaScript
'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;
|