Files
competition-website/app/service/admin.js
qubiaobiao 95ad1adca4 fix(deps): xlsx → @e965/xlsx@0.20.3(消除两处已知 high 供应链漏洞)
背景:npm 上的 xlsx 永久冻结在 0.18.5(SheetJS 已停止向 npm 发布),该版本含
CVE-2023-30533(原型污染,0.19.3 修复)与 CVE-2024-22363(ReDoS,0.20.2 修复)两处 high,
而 npm 上**不存在**修复版本——官方修复版只发自家 CDN。

选型判据=CDN 可达性:本机实测 cdn.sheetjs.com 不可达(curl rc=35 schannel 吊销离线;
--ssl-no-revoke 后 rc=28 超时),采用官方 CDN tarball 会把「依赖能否装上」变成部署单点。
故改用 npm 上忠实转发官方 CDN 构建的 @e965/xlsx(0.20.3,含上述两处修复、零依赖、
API 与 xlsx 全等)。未选 exceljs:其 API 完全不同,11 个调用点需全量重写且写入变异步,
风险直接落在承载生产导入路径的代码上。

- package.json:xlsx@^0.18.0 → @e965/xlsx@^0.20.3
- app/service/admin.js(成绩导出 / 客观题模板 / 报名表导入 / 客观题导入)
  app/service/bigQuestion.js(综合大题模板 / 综合大题导入):仅改 require 包名,
  业务逻辑零改动(共 6 条路径 / 11 个调用点)
- package-lock.json:移除 xlsx 及其 7 个传递依赖(cfb/codepage/ssf/wmf/word/adler-32/frac),
  净 −7 包

验证:API 面 + 写读往返 PASS;差分测试 24/24(含畸形尺寸样本与 201 行模板);
真实 HTTP 回归 6 条路径 PASS(写 3/3 均 PK 头、读 3/3 正效应且落库计数吻合);
跨库往返 6/6;npm audit 中 xlsx 命中【无】(backend high 23 → 22)。
2026-10-10 15:15:37 +08:00

2654 lines
109 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');
// 2026-10-10 换库:理由同 service/bigQuestion.js —— npm 上 `xlsx` 冻结在 0.18.5(两处 high CVE),
// 官方修复版仅在不可达的 cdn.sheetjs.com ⇒ 改用零依赖、API 一致的 `@e965/xlsx@0.20.3`。
const XLSX = require('@e965/xlsx');
const {
buildAggregationKeyMap,
resolveAggregationKeysForContestant,
} = require('../lib/aggregationKey');
/**
* 客观题(选择题 / 判断题)每小题固定分值。
*
* 赛制口径(2026-09-23 统一):中职 / 高职组的选择题与判断题**每小题 1 分**,
* 客观题满分 = 抽题数(50 + 20 = 70)。
* 此前分值可由管理端任意设置、且 Excel 导入缺省 5 分,一旦「5 分题的答案
* 恰好全是 A / true」,考生全选 A 即可白拿高分(实测高职判断题得分率 95.3%)。
* 综合性任务(word)不在本约束范围内。
*/
const OBJECTIVE_SCORE_PER_QUESTION = 1;
const OBJECTIVE_TYPES = ['choice', 'judge'];
/**
* 管理后台服务 - 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,
};
}
// ==================== 账号管理 ====================
/**
* 获取分页账号列表
*
* R90 增补:每行附带 `teamUid / teamId / teamNumber / schoolName`(与 getTeams 同源口径),
* 供「选手管理 → 选手列表」与「小组视图」相互对照(人工测试反馈:选手列表缺小组编号列)。
* 既有字段一律不变。
*
* @param {Object} options - 查询选项 { group, keyword, activated, page, pageSize, startDate, endDate }
* @returns {Promise<Object>} { list, total, page, pageSize }
*/
async getAccounts(options = {}) {
const { ctx } = this;
const { group, keyword, activated, 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;
}
// 激活状态筛选(2026-10-08 修复:此前 controller 未透传、本函数也未解构该参数,
// ⇒ 选手管理页「激活状态」下拉完全失效且不报错)。
// ⚠️ 前端 el-select 传的是字符串 'true'/'false',**不能直接塞进 where**:
// activated 是 BOOLEAN(TINYINT(1)) 列,MySQL 会把 'true' 隐式转成 0,
// 结果「已激活」错查出「未激活」。必须显式归一化为布尔。
// 白名单之外的值(含 undefined/'' 等)一律视为「未指定」忽略,
// 避免畸形入参被当成 false 而静默缩窄结果集。
if ([true, 1, '1', 'true'].includes(activated)) {
where.activated = true;
} else if ([false, 0, '0', 'false'].includes(activated)) {
where.activated = false;
}
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,
});
// ---- R90:补齐「小组编号 / 所属学校」(与 getTeams 同源口径,供选手列表对照)----
// 口径(勿另立第二套):
// · 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)
// · teamUid 解析优先级:aggregation.team_uid(报名表聚合,权威) → contestants.team_id(兜底)
// · schoolName 取自 aggregation.organization_cn
// 批量查询(2 条 SQL)而非逐行查,避免 N+1。
const Op = ctx.model.Sequelize.Op;
const pageIds = rows.map(r => r.id);
const aggTeamUid = new Map(); // contestant.id → teamUid
const aggSchool = new Map(); // contestant.id → 学校名
if (pageIds.length > 0) {
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: { [Op.in]: pageIds } },
attributes: ['account_uid', 'team_uid', 'organization_cn'],
raw: true,
});
for (const a of aggRows) {
if (a.team_uid !== null && a.team_uid !== undefined && !aggTeamUid.has(a.account_uid)) {
aggTeamUid.set(a.account_uid, a.team_uid);
}
const sch = typeof a.organization_cn === 'string' ? a.organization_cn.trim() : '';
if (sch && !aggSchool.has(a.account_uid)) aggSchool.set(a.account_uid, sch);
}
}
// 队伍元信息(人类可读队号 team_id / team_number),按 (group, teamUid) 建索引
const resolveTeamUid = row => {
if (aggTeamUid.has(row.id)) return aggTeamUid.get(row.id);
return row.team_id === null || row.team_id === undefined ? null : row.team_id;
};
const teamUids = Array.from(new Set(rows.map(resolveTeamUid).filter(u => u !== null)));
const teamMeta = new Map();
if (teamUids.length > 0) {
const teamRows = await ctx.model.Team.findAll({
where: { team_uid: { [Op.in]: teamUids } },
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
for (const t of teamRows) teamMeta.set(t.group_name + '#' + t.team_uid, t);
}
// 转换为前端期望的字段格式
const list = rows.map(row => {
const teamUid = resolveTeamUid(row);
const meta = teamUid === null ? null : (teamMeta.get(row.group + '#' + teamUid) || null);
return {
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,
// R90 新增(既有字段一律不变)
teamUid,
teamId: meta ? meta.team_id : null,
teamNumber: meta ? (meta.team_number || meta.team_id) : null,
schoolName: aggSchool.get(row.id) || '',
};
});
return {
list,
total: count,
page: parseInt(page),
pageSize: parseInt(pageSize),
};
}
/**
* 获取「小组(团队)激活状态」清单(R84 续六新增 · 人工测试反馈需求)
*
* 需求原文:「在选手管理中加入一个查看已激活/未激活小组的功能,且必须携带小组所属学校名称」。
*
* 口径(本函数的**唯一真相源**,改动前请先读这里):
* - **小组的键 = (组别 group, 队伍号 teamUid)**。⛔ 绝不能用 teamUid 单独作键:
* 实测 `team_uid=1` 同时存在于 zhongzhi 与 benke(见 `service/exam.js` checkTeamLock
* 的跨组复用防护注释),单独用 teamUid 会把两个不同组的小组并成一个。
* - **成员来源为双来源并集**(与 checkTeamLock 保持一致):
* ① `aggregation.team_uid`(报名表聚合填充,**同时是学校名称的来源**)
* ② `contestants.team_id`(导入流程填充,兼容旧数据)
* 同一选手若两处都有归属,**只计一次**(以 aggregation 为准,避免重复计数)。
* - **学校名称**取自 `aggregation.organization_cn`。同一小组理论上同校;
* 若出现多校(异常数据),并列展示并置 `schoolConflict=true` 供排查,**不静默取一个**。
* - **三态判定**(字段 `status`):`activated`(全员激活)/ `partial`(部分激活)/ `inactive`(全未激活)。
* - **🔴「已激活」语义(R90 续三修正)**:行字段 `activated` = 组内**至少 1 名**成员已激活
* —— 设计目的即为「确认该小组是否已有人激活、**可以参赛**」,故 `partial` 归入**已激活**侧,
* 仅 `inactive` 归入**未激活**侧(旧口径「全员激活」与产品语义不符,已修正)。
* 严格「全员激活」语义另存于 `allActivated`;前端表格按 `status` 渲染三态标签,显示不受影响。
* - **测试数据口径**:默认 `includeTest=false` ⇒ 只统计 `test_flag=0` 的成员;
* 某小组若成员全为测试数据,则该小组整体不出现(这正是「生产口径」想要的)。
*
* @param {Object} options - { group?, activated?, includeTest? }
* @returns {Promise<Object>} { list, summary, filteredTotal, filters }
* summary 为**筛选前全集**口径,含 total/activated/partial/inactive/memberTotal/memberActivated
* 以及 R90 增补的 memberActivatedInPartial / filteredTotal / filteredMemberTotal / filteredMemberActivated
* 与 R90 续三增补的 filteredActivated / filteredPartial / filteredInactive(筛选后三态分布)
*/
async getTeams(options = {}) {
const { ctx } = this;
const { group, activated } = options;
const includeTest = options.includeTest === true || options.includeTest === 'true' || options.includeTest === '1';
const GROUP_CN = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
// ---- 1) 成员(选手):只取本功能所需列,避免把 password 等带出来 ----
const contestantWhere = {};
if (group) contestantWhere.group = group;
if (!includeTest) contestantWhere.test_flag = 0;
const contestants = await ctx.model.Contestant.findAll({
where: contestantWhere,
attributes: ['id', 'group', 'team_id', 'activated'],
raw: true,
});
const contestantById = new Map();
for (const c of contestants) contestantById.set(c.id, c);
// ---- 2) 聚合报名(团队归属 + 学校名称)----
const aggs = await ctx.model.Aggregation.findAll({
attributes: ['account_uid', 'team_uid', 'organization_cn'],
raw: true,
});
// ---- 3) 团队元信息(人类可读队伍编号 team_id / team_number)----
const teamWhere = group ? { group_name: group } : {};
const teamRows = await ctx.model.Team.findAll({
where: teamWhere,
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
const teamMeta = new Map();
for (const t of teamRows) teamMeta.set(t.group_name + '#' + t.team_uid, t);
// ---- 4) 归集:(组别, 队伍号) → 成员 / 激活数 / 学校 ----
const buckets = new Map();
const keyOf = (g, uid) => g + '#' + uid;
const touch = (g, uid) => {
const k = keyOf(g, uid);
let b = buckets.get(k);
if (!b) {
b = { group: g, teamUid: uid, memberIds: new Set(), activatedCount: 0, schools: new Set() };
buckets.set(k, b);
}
return b;
};
// 4a) 来源一:aggregation.team_uid(先跑,作为「已归属」判定的基准)
const assigned = new Set();
for (const a of aggs) {
if (a.team_uid === null || a.team_uid === undefined) continue;
const c = contestantById.get(a.account_uid);
if (!c) continue; // 不在本次口径内(被组别/测试数据过滤掉)
const b = touch(c.group, a.team_uid);
if (!b.memberIds.has(c.id)) {
b.memberIds.add(c.id);
if (c.activated) b.activatedCount += 1;
}
assigned.add(c.id);
const sch = typeof a.organization_cn === 'string' ? a.organization_cn.trim() : '';
if (sch) b.schools.add(sch);
}
// 4b) 来源二:contestants.team_id(兜底;已由 aggregation 计过的选手跳过 ⇒ 防重复计数)
for (const c of contestants) {
if (c.team_id === null || c.team_id === undefined) continue;
if (assigned.has(c.id)) continue;
const b = touch(c.group, c.team_id);
if (!b.memberIds.has(c.id)) {
b.memberIds.add(c.id);
if (c.activated) b.activatedCount += 1;
}
}
// ---- 5) 组装 + 状态判定 ----
const all = [];
for (const b of buckets.values()) {
const memberCount = b.memberIds.size;
const activatedCount = b.activatedCount;
const meta = teamMeta.get(keyOf(b.group, b.teamUid)) || null;
const status = activatedCount === 0
? 'inactive'
: (activatedCount === memberCount ? 'activated' : 'partial');
all.push({
group: b.group,
groupName: GROUP_CN[b.group] || b.group,
teamUid: b.teamUid,
// teams 表可能没有对应行(旧数据只写了 aggregation)⇒ 回退为空,前端展示「—」
teamId: meta ? meta.team_id : null,
teamNumber: meta ? (meta.team_number || meta.team_id) : null,
schoolName: Array.from(b.schools).join(' / '),
schoolConflict: b.schools.size > 1,
memberCount,
activatedCount,
// 🔴 R90 续三:「已激活」= 组内**至少 1 名**成员已激活(= 可以参赛),partial 归入已激活侧。
// 旧口径「全员激活」与产品语义(确认小组是否已有人激活可参赛)不符,已修正。
// 筛选逻辑随之无需改动(下方 list 过滤仍按本字段判定)。
activated: memberCount > 0 && activatedCount > 0,
// 严格「全员激活」语义(前端表格按 status 渲染三态标签,本项供需严格判定的场景)
allActivated: memberCount > 0 && activatedCount === memberCount,
status,
});
}
// 稳定排序:组别 → 队伍号(数值升序;非数值队号排后面)
const GROUP_ORDER = { zhongzhi: 0, gaozhi: 1, benke: 2, jiaoshi: 3 };
all.sort((x, y) => {
const gx = GROUP_ORDER[x.group] === undefined ? 9 : GROUP_ORDER[x.group];
const gy = GROUP_ORDER[y.group] === undefined ? 9 : GROUP_ORDER[y.group];
if (gx !== gy) return gx - gy;
return (x.teamUid || 0) - (y.teamUid || 0);
});
const summarize = arr => ({
total: arr.length,
activated: arr.filter(t => t.status === 'activated').length,
partial: arr.filter(t => t.status === 'partial').length,
inactive: arr.filter(t => t.status === 'inactive').length,
memberTotal: arr.reduce((s, t) => s + t.memberCount, 0),
memberActivated: arr.reduce((s, t) => s + t.activatedCount, 0),
});
// 筛选(与 getAccounts 的 activated 归一化同理:前端 el-select 传字符串)
// 🔴 R90 续三口径:activated=true ⇒「至少 1 名成员已激活」= activated 组 + partial 组(可参赛);
// activated=false ⇒ 仅 inactive 组(无任何成员激活)。判据统一挂在行字段 activated 上。
let list = all;
if ([true, 1, '1', 'true'].includes(activated)) {
list = all.filter(t => t.activated === true);
} else if ([false, 0, '0', 'false'].includes(activated)) {
list = all.filter(t => t.activated === false);
}
// R90:给前端「双口径」汇总用(人工测试反馈:筛选后小组数 3 与成员激活数 4 看似矛盾)。
// 既有 6 个字段语义一律不变(仍为**筛选前全集**口径),仅新增下列字段:
// · memberActivatedInPartial —— 「部分激活」组内已激活的成员数(恒为全集口径)。
// · filteredTotal / filteredMemberTotal / filteredMemberActivated —— **当前筛选后**口径。
// R90 续三:口径修正为「activated = 至少 1 名成员激活」后,选中「已激活」时 partial 组一并纳入
// ⇒ filteredMemberActivated 恒等于全集 memberActivated(inactive 组贡献 0),
// 「选手列表激活人数」与「已激活小组成员数」不再有差额。
// 另补 filteredActivated / filteredPartial / filteredInactive,供前端展示筛选后三态分布。
const allSummary = summarize(all);
const filteredSummary = summarize(list);
return {
list,
summary: {
...allSummary,
memberActivatedInPartial: all
.filter(t => t.status === 'partial')
.reduce((s, t) => s + t.activatedCount, 0),
filteredTotal: filteredSummary.total,
filteredMemberTotal: filteredSummary.memberTotal,
filteredMemberActivated: filteredSummary.memberActivated,
// R90 续三:筛选后三态分布(选中「已激活」时 = 全员激活 + 部分激活)
filteredActivated: filteredSummary.activated,
filteredPartial: filteredSummary.partial,
filteredInactive: filteredSummary.inactive,
},
// summary 作用于**筛选前**的全集(给总览用);filteredTotal 说明筛选后条数
filteredTotal: list.length,
filters: { group: group || '', activated: activated === undefined ? '' : activated, includeTest },
};
}
// ==================== 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;
};
// 列名别名归一:上游赛事系统导出的「报名信息表」与后端内部列名契约不一致
// (「身份证号码」vs「身份证号」、「全名」vs「姓名」、「队伍ID」vs「队伍编号」),
// 直接导入会因取不到列而把整表判为「身份证号为空」全量跳过(R31 实测 0/1225)。
// 数组顺序即优先级;取首个非空值,使上游导出表无需人工改列名即可导入。
const COLUMN_ALIAS = {
身份证号: ['身份证号', '身份证号码', '身份证', 'id_number'],
姓名: ['姓名', '全名', 'name'],
队伍编号: ['队伍编号', '队伍ID', '队伍id', 'team_id', 'team_number'],
手机号: ['手机号', '手机号码', '联系电话', 'phone'],
组别: ['组别', 'group'],
身份: ['身份', 'identity'],
// R84 续六新增:上游「报名信息表」确实带「学校/单位名称全称」列,但此前列名契约
// **有意忽略**该列 ⇒ 落库时丢失 ⇒ 生产库 `aggregation.organization_cn`
// 600 行全为空(2026-10-08 只读取证)⇒「小组所属学校」无从展示。
// 现补入契约,写入 `aggregation.organization_cn`(该列运行库既有,模型声明见
// `model/aggregation.js`)。列名以上游导出为准,另兼容几种常见写法以防改版。
学校: ['学校/单位名称全称', '学校/单位名称全称(英文)', '学校名称', '单位名称', '学校', 'organization_cn'],
};
// 按别名顺序取首个非空值(统一 trim,返回字符串)
const pick = (row, key) => {
const aliases = COLUMN_ALIAS[key] || [key];
for (const k of aliases) {
const v = row[k];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
}
return '';
};
// 只导入「参赛选手」:报名审核表同一队伍内含报名人 / 指导教师(专家、教练) / 团队支持(领队) 等身份,
// 这些身份不建立参赛账号(不参与考试与作品提交)。归一化去空白后精确匹配,
// 避免因上游表格中的空白字符差异导致漏导。
const IMPORT_IDENTITY = '参赛选手';
const normalizeIdentity = (s) => String(s || '').replace(/\s+/g, '');
const isImportableIdentity = (s) => normalizeIdentity(s) === IMPORT_IDENTITY;
// 是否将本批次标记为测试数据(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,
// 非「参赛选手」身份被主动忽略的行数(业务规则过滤,非异常,不计入 skipped/errors)
ignored: 0,
ignoredBy: {},
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 = pick(row, '队伍编号');
const group = normalizeGroup(pick(row, '组别'));
// 只为参赛选手建队:报名人/指导教师/领队不参与建队,避免留下无人参赛的空团队
if (!isImportableIdentity(pick(row, '身份'))) continue;
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++;
}
// 写入团队自身的 team_uid:新建时为刚生成的 uid,库内复用时为真实 uid。
// (旧代码固定写 maxTeamUid,复用库内已存在团队时会把后续选手挂到错误的团队 uid)
teamUidCache[cacheKey] = team.get('team_uid');
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowIndex = i + 2;
// 身份过滤(事务之外):只导入参赛选手,其余身份计入 ignored 明细后跳过。
// 置于事务外可避免为被忽略行白开事务(本表约一半行属于此类)。
const identityRaw = pick(row, '身份');
if (!isImportableIdentity(identityRaw)) {
result.ignored++;
const label = identityRaw || '(空)';
result.ignoredBy[label] = (result.ignoredBy[label] || 0) + 1;
continue;
}
// 整行事务:队伍 + 选手 + 聚合记录要么一起成功,要么整体回滚
const t = await ctx.model.transaction();
try {
const idNumber = pick(row, '身份证号');
const name = pick(row, '姓名');
const phone = pick(row, '手机号');
const group = normalizeGroup(pick(row, '组别'));
// 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁)
const teamIdRaw = pick(row, '队伍编号');
// 聚合记录的身份口径与作品/答题列表保持一致(作品列表统一记为「选手」)
const identity = '选手';
// R84 续六:学校 / 单位名称(上游「报名信息表」的「学校/单位名称全称」列)
const schoolName = pick(row, '学校');
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;
}
// 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提)
let teamUid = null;
if (teamIdRaw) {
teamUid = teamUidCache[teamIdRaw + '|' + group];
}
// 重复判定:身份证号全局唯一,且业务上同一人不会分配两个身份证号
// ⇒ 身份证号相同即同一人(无需「队伍+姓名+手机号」组合键;同名不同证者各自导入)。
// 消息中附带「队伍 / 姓名 / 手机号」,便于人工在同名人员中定位到具体是哪一条。
const existing = await ctx.model.Contestant.findOne({
where: { id_number: idNumber },
transaction: t,
});
if (existing) {
result.errors.push({
row: rowIndex,
message: `身份证号 ${idNumber} 已存在(队伍 ${teamIdRaw || '(单人参赛)'} / ${name} / ${phone || '(无手机号)'})`,
});
result.skipped++;
await t.rollback();
continue;
}
// 生成密码:测试数据统一使用「默认口令」的 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 || '',
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,
// R84 续六:把「学校/单位名称全称」落库,供「选手管理 · 小组视图」展示所属学校。
// 取不到时写 null(而非 ''),与「未填写」语义一致。
organization_cn: schoolName || null,
}, { transaction: t });
}
// ⚠️ R84 续六注记(勿误以为「再导一次就能补学校」):
// 本函数在第 541-553 行已按 `id_number` 判重,**已存在的选手会整行 skip 并 rollback**,
// 根本走不到这里 ⇒ **重复导入不会回填历史行的学校**。
// 历史数据(生产 600 行 organization_cn 全空)必须走一次性回填工具:
// `scripts/backfill_organization_cn.py`(默认 dry-run,只填空、不覆盖)。
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']],
});
// 归集键统一由 lib/aggregationKey 提供(单一真相源):
// 主键按组语义确定(中职/高职 = contestant.id;本科/教师 = 真实 aggregation_uid),
// 兜底键沿用历史混杂写法,但**仅在不与同组任何主键冲突、且不被多人共用时才启用**。
// 旧实现无条件双键归集 ⇒ 两套独立自增序列数值区间重叠时会命中他人作答记录(生产 283 对碰撞)。
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 aggKeysByContestant = buildAggregationKeyMap(group, contestants, realAggByContestant);
// 获取所有提交记录
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 => {
// 归集键(含歧义消解后的兜底键);首元素必为主键
const candidateKeys = aggKeysByContestant[String(c.id)] || [String(c.id)];
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,
wordScore: c.word_score ?? 0,
// 总分 = 客观题自动评分合计 + 综合大题阅卷分(与「卷面分 / 大题分」口径一致);
// 不读 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: c.exam_score ?? 0` 重复定义(对象字面量重复键,后者静默胜出);
// 虽因 updateScores 会同步写 exam_score 而「结果碰巧一致」,但属隐患,故删除上方那条只留本条。
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 / Excel,R29) ====================
/**
* 成绩导出**单一数据源**:表头 + 数据行。
*
* CSV 与 Excel 两个出口都必须走这里 —— 若各写一套,迟早出现
* 「Excel 加了新列、CSV 没加」这类表头漂移(本项目已被同类问题咬过多次)。
*
* @param {string} group - 组别
* @returns {Promise<{headers: string[], rows: Array<Array<string|number>>}>}
*/
async buildScoreExportTable(group) {
const isExam = group === 'zhongzhi' || group === 'gaozhi';
if (isExam) {
// 中职/高职组:客观题(系统自动评分) + 综合大题(人工阅卷)
// 说明:大题分落库列名为 contestants.word_score(历史沿用,语义已变为「综合大题得分」),
// 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
const submissions = await this.getExamSubmissions(group);
return {
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '客观题分', '大题分', '总分'],
rows: submissions.map(item => [
item.contestantId,
item.idNumber || '',
item.userName || '',
item.phone || '',
item.answeredCount > 0 ? '是' : '否',
item.examScore ?? 0,
item.wordScore ?? 0,
item.totalScore ?? 0,
]),
};
}
// 本科/教师组:Word(人工) + PPT(人工)
const submissions = await this.getSubmissions(group);
return {
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '文档文件名', '文档大小', '文档分', 'PPT文件名', 'PPT大小', 'PPT分'],
rows: submissions.map(item => {
const account = item.account || {};
const wordSub = item.wordSubmission;
const pptSub = item.pptSubmission;
return [
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 : '',
];
}),
};
}
/**
* 导出某组提交记录为 CSV 字符串
*
* ⚠️ 该出口**保留**:`scripts/verify_full_e2e.py`(B10 / D10)与
* `scripts/verify_upload_download.py`(B2)都以它做「导出链路非空」断言,
* 删掉会让门禁变红。管理端界面已改走 Excel(见 exportToXlsx)。
*
* @param {string} group - 组别
* @returns {Promise<string>} CSV 内容
*/
async exportToCsv(group) {
const { headers, rows } = await this.buildScoreExportTable(group);
// 字符串一律加引号(原文对身份证 / 姓名 / 手机号 / 文件名即如此),数值保持裸值
const cell = v => {
if (v == null) return '';
return typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : String(v);
};
return [headers, ...rows].map(r => r.map(cell).join(',')).join('\n') + '\n';
}
/**
* 导出某组成绩为真正的 Excel(.xlsx)(R29 需求)。
*
* 测试人员反馈「导出 CSV 不如直接导出 Excel」:CSV 在 Excel 中打开时,
* 中文易乱码、18 位身份证号 / 11 位手机号会被转成科学计数法,需手工「分列」。
* 直接用已装依赖 xlsx(SheetJS) 生成 xlsx,打开即为规整表格。
*
* @param {string} group - 组别
* @returns {Promise<Buffer>} xlsx 二进制
*/
async exportToXlsx(group) {
const { headers, rows } = await this.buildScoreExportTable(group);
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
// 列宽:文件名较长、身份证号 18 位,给足宽度让导出即可读
ws['!cols'] = headers.map(h => {
if (/文件名/.test(h)) return { wch: 32 };
if (/身份证号/.test(h)) return { wch: 20 };
return { wch: 12 };
});
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '成绩单');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
// ==================== 批量下载 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}`);
}
}
/**
* 计算题目的落库分值。
*
* 客观题(choice / judge)一律强制为 `OBJECTIVE_SCORE_PER_QUESTION`(=1),
* 忽略调用方传入的任何分值;综合性任务(word)保留传入分值(须为正数)。
*
* @param {string} questionType - choice / judge / word
* @param {*} rawScore - 调用方传入的分值(客观题会忽略)
* @returns {number} 实际落库分值
*/
_resolveQuestionScore(questionType, rawScore) {
if (OBJECTIVE_TYPES.includes(questionType)) {
return OBJECTIVE_SCORE_PER_QUESTION;
}
const n = Number(rawScore);
if (!isFinite(n) || n <= 0) {
throw new Error('综合性任务分值(score)必须为大于 0 的合法数值');
}
return n;
}
/**
* 管理考试题目(替换操作:先清空再创建)
* @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) => {
// 客观题(choice/judge)分值由服务层强制为 1(赛制口径),忽略传入值
const scoreNum = this._resolveQuestionScore(questionType, q.score);
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);
// 客观题(choice/judge)一律 1 分(赛制口径);word 题型保留传入分值
const scoreNum = this._resolveQuestionScore(questionType, questionData.score);
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 = {
// 客观题分值恒为 1(忽略传入值,防止管理端改回 5 分);word 题型沿用原逻辑
score: OBJECTIVE_TYPES.includes(questionType)
? OBJECTIVE_SCORE_PER_QUESTION
: (questionData.score !== undefined ? Number(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,
});
// 归集键统一由 lib/aggregationKey 提供(单一真相源,见模块头注释):
// 主键按组语义确定,兜底键仅在无歧义时启用。
// ⚠️ 旧实现无条件双键归集 ⇒ 「真实聚合 uid」撞上同组他人 contestant.id 时,
// 会把他人作答记录(答题数/卷面分)计入本行,生产已复现(cid=600 命中 cid=621)。
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 aggKeysByContestant = buildAggregationKeyMap(group, contestants, realAggByContestant);
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 = aggKeysByContestant[String(item.uid)] || [String(item.uid)];
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 = aggKeysByContestant[String(item.uid)] || [String(item.uid)];
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);
// 客观题只能被设为 1 分(赛制口径);word 题型按传入值设置
const resolved = this._resolveQuestionScore(questionType, score);
const [updated] = await QuestionModel.update(
{ score: resolved },
{ 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;
}
}
// 客观题分值恒为 1(赛制口径)。Excel 的「分值」列一律忽略 ——
// 旧逻辑在缺列/非法时缺省 5 分,正是「高分题」污染题库的来源之一。
const score = OBJECTIVE_SCORE_PER_QUESTION;
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() {
// 「分值」列已废弃(客观题恒为 1 分),保留列名仅为兼容旧模板与旧脚本
const header = ['题型', '题目', '选项A', '选项B', '选项C', '选项D', '正确答案', '分值'];
const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 1];
const ws = XLSX.utils.aoa_to_sheet([header, example]);
// 判断题示例行(另起一行,便于参考)
XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 1]], { origin: -1 });
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '客观题');
// ⚠️ sheet 顺序是**契约**:导入侧(importQuestionsFromExcel)按 `SheetNames[0]` 解析,
// 因此数据 sheet 必须排第 1,「填写说明」只能排第 2(删掉也不影响导入)。
// R73:测试人员反馈「不知道导入模板长什么样」⇒ 让模板自解释,不必依赖外部文档。
const notes = [
['客观题导入模板 — 填写说明'],
[],
['列名', '是否必填', '说明'],
['题型', '必填', '只能填「选择题」或「判断题」(英文 choice / judge 也能识别)'],
['题目', '必填', '题干内容'],
['选项A', '选填', '选项内容。选择题至少需 2 个非空选项'],
['选项B', '选填', ''],
['选项C', '选填', ''],
['选项D', '选填', ''],
['正确答案', '必填', '选择题填选项字母(如 C,多选如 AC);判断题填「对」或「错」'],
['分值', '已废弃', '客观题分值口径为「每小题固定 1 分」,本列会被忽略,无需填写'],
[],
['【注意事项】'],
['· 表头必须位于第 1 行,请勿删除或改写表头文字。'],
['· 一行一道题;判断题请把选项列(A..D)留空。'],
['· 导入完成后请在题库列表中核对数量是否与预期一致。'],
];
const notesWs = XLSX.utils.aoa_to_sheet(notes);
notesWs['!cols'] = [{ wch: 14 }, { wch: 10 }, { wch: 66 }];
XLSX.utils.book_append_sheet(wb, notesWs, '填写说明');
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 } });
}
// ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
// 两条取数通路(reviewSearch → getContestantScore)**只允许挂在 admin 路由下**。
/**
* 检索考生(复核入口)
*
* 命中口径:姓名 / 身份证号 / 电话 **任一** 模糊匹配(用户明确要求),且**不限组别** ——
* 复核时管理员通常只拿到一个身份证号或姓名,不该被迫先猜组别;结果里带 group 供界面展示。
*
* @param {string} keyword - 检索词(姓名 / 身份证号 / 电话片段)
* @param {string} [group] - 可选:限定组别
* @returns {Promise<{ok:boolean, code?:string, message?:string, list?:Array, truncated?:boolean}>}
*/
async reviewSearch(keyword, group) {
const { ctx } = this;
const kw = String(keyword == null ? '' : keyword).trim();
// 空关键字返回空列表而非全表:避免误操作把整库考生拖给前端。
if (!kw) return { ok: true, list: [], truncated: false };
if (kw.length > 64) {
return { ok: false, code: 'BAD_KEYWORD', message: '检索词过长(上限 64 字符)' };
}
const { Op } = ctx.model.Sequelize;
const like = { [Op.like]: `%${kw}%` };
const where = { [Op.or]: [{ name: like }, { id_number: like }, { phone: like }] };
if (group) where.group = group;
const LIMIT = 50;
const rows = await ctx.model.Contestant.findAll({
where,
attributes: ['id', 'name', 'id_number', 'phone', 'group'],
order: [['group', 'ASC'], ['id', 'ASC']],
limit: LIMIT,
raw: true,
});
return {
ok: true,
list: rows.map(c => ({
contestantId: c.id,
name: c.name || '',
idNumber: c.id_number || '',
phone: c.phone || '',
group: c.group,
// 客观题只存在于中职/高职;本科/教师的「卷面」是作品文件。界面据此切换展示与提示。
objectiveApplicable: c.group === 'zhongzhi' || c.group === 'gaozhi',
})),
// 命中被截断时如实上报,引导管理员补充更多字符(而非静默只展示前 50 条)。
truncated: rows.length >= LIMIT,
};
}
/**
* 成绩复核:取某考生的**全卷**明细。
*
* 输出 = 客观题逐题(题干 / 选项 / 考生答案 / 正确答案 / 对错 / 得分 / 满分)
* + 综合大题(题干快照 + 三小问作答 + 三小问得分)
* + 本科/教师组的作品文件清单。
*
* 设计约束(每条都对应一个已发生过的坑):
* 1. **归集口径必须与 getExamSubmissions 完全一致**(双键:contestant.id 与真实 aggregation_uid)。
* 否则会出现「列表页说已答 70 题、复核页只有 0 题」—— 管理员会先怀疑数据被篡改。
* 2. **组别以考生自身的 group 为准**,并校验入参 group:防跨组串查。
* 3. 题目按 **exam_submissions.question_order(考生固化的抽题顺序)** 排列,复现他当时所见的题序;
* 无固化顺序的历史数据回退为「选择在前、判断在后,各自按题号升序」。
* 4. `question.answer` **必须回传**(复核场景用户明确要求给出正确答案)。
* ⚠️ 因此本方法**只能挂 admin 路由**:一旦被选手端复用即为成绩泄露
* (参见 scripts/verify_score_leak_all_channels.py 所守的三条通道)。
* 也正因如此这里**不做** isScorePublished 门控 —— admin 是可信边界,
* 而内部核对往往发生在发布之前;发布态只在返回值里**如实上报**供界面提示。
* 5. 题库中已删除 / 改组导致取不到题目时标 `questionMissing`,而非静默留空 ——
* 复核要能区分「考生没答」与「题没了」。
*
* @param {number} contestantId - contestants.id
* @param {string} [group] - 调用方声明的组别(须与考生实际组别一致)
* @returns {Promise<Object>} { ok, ... } ok=false 时带 code/message(不抛异常)
*/
async getContestantScore(contestantId, group) {
const { ctx } = this;
const { Op } = ctx.model.Sequelize;
const cid = parseInt(contestantId, 10);
if (!Number.isFinite(cid)) {
return { ok: false, code: 'BAD_ID', message: '考生 ID 非法' };
}
const contestant = await ctx.model.Contestant.findOne({
where: { id: cid },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'],
raw: true,
});
if (!contestant) return { ok: false, code: 'NOT_FOUND', message: '考生不存在' };
if (group && group !== contestant.group) {
return { ok: false, code: 'GROUP_MISMATCH', message: `组别不符:该考生属于「${contestant.group}」组` };
}
const g = contestant.group;
const isExamGroup = g === 'zhongzhi' || g === 'gaozhi';
const base = {
contestantId: cid,
name: contestant.name || '',
idNumber: contestant.id_number || '',
phone: contestant.phone || '',
group: g,
objectiveApplicable: isExamGroup,
scorePublished: await this.isScorePublished(g), // 只上报,不门控(见方法头第 4 条)
};
if (!isExamGroup) {
// 本科/教师:无客观题,「卷面」即提交的作品文件(Word / PPT)。
return {
ok: true,
...base,
objective: [],
summary: { answered: 0, total: 0, correctCount: 0, autoScore: 0 },
bigQuestion: null,
works: await this._reviewWorks(cid, g),
warnings: [],
};
}
// ---- 1) 归集键(单一真相源 lib/aggregationKey,与「考生答题记录」完全同口径) ----
// 兜底键仅在无歧义(不被同组他人当作主键)时启用,
// 避免「真实聚合 uid 撞上同组他人 contestant.id」而把他人作答记录并入本复核页。
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: cid },
attributes: ['aggregation_uid'],
raw: true,
});
const keys = await resolveAggregationKeysForContestant(
ctx,
g,
cid,
agg ? agg.aggregation_uid : null
);
const SubmissionModel = g === 'zhongzhi' ? ctx.model.ZhongzhiSubmission : ctx.model.GaozhiSubmission;
const subs = await SubmissionModel.findAll({
where: { aggregation_uid: { [Op.in]: keys.map(Number) } },
attributes: ['id', 'aggregation_uid', 'question_uid', 'question_type', 'answer_data', 'is_correct', 'score'],
order: [['id', 'ASC']],
raw: true,
});
const warnings = [];
const usedKeys = [ ...new Set(subs.map(s => String(s.aggregation_uid))) ];
if (usedKeys.length > 1) {
warnings.push('该考生的作答记录同时落在两种聚合键下(' + usedKeys.join(' / ') +
'),已按与「考生答题记录」一致的口径合并归集;若题数异常请人工核对。');
}
// ---- 2) 固化题序(复现考生当时所见次序) ----
const examSub = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: cid },
attributes: ['question_order', 'submit_time'],
raw: true,
});
const rank = new Map();
if (examSub && examSub.question_order) {
try {
const parsed = JSON.parse(examSub.question_order);
if (Array.isArray(parsed)) parsed.forEach((u, i) => rank.set(String(u), i));
} catch (e) {
// 脏数据不抛异常:回退默认排序(复核页不应因一条脏记录整体打不开)
ctx.logger.warn('[getContestantScore] question_order 解析失败 cid=%s', cid);
}
}
// ---- 3) 批量取题干(按题型分表;必须带 group 限定,防跨组同号题串台) ----
const idsByType = { choice: [], judge: [] };
for (const s of subs) {
const t = s.question_type;
// 白名单判定,避免 'constructor' 之类字符串命中 Object 原型链
if (!Object.prototype.hasOwnProperty.call(idsByType, t)) continue;
const n = Number(s.question_uid);
if (Number.isFinite(n)) idsByType[t].push(n);
}
const qMap = new Map();
for (const t of [ 'choice', 'judge' ]) {
const ids = idsByType[t];
if (!ids.length) continue;
const Model = t === 'choice' ? ctx.model.ChoiceQuestion : ctx.model.JudgeQuestion;
const rows = await Model.findAll({ where: { id: { [Op.in]: ids }, group: g }, raw: true });
rows.forEach(r => qMap.set(`${t}:${r.id}`, r));
}
// ---- 4) 拼装逐题明细 ----
const norm = v => String(v == null ? '' : v).trim().toLowerCase();
// 【R28 修复】options 在 DB 里是 text 列、存的是 JSON 字符串。
// 若原样透传给前端,`v-for="op in row.options"` 会按**字符**迭代 ⇒ 每个字符渲染成一行
// 空内容(op.key / op.text 皆 undefined),表现即「选择题大量换行导致空白」。
// 写法与 admin.js:1370 既有口径同源:仅在 typeof === 'string' 时 parse,失败降级 null。
const parseOptions = raw => {
if (raw == null) return null;
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return null;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch (e) {
return null;
}
};
const objective = subs.map(s => {
const t = s.question_type;
const n = Number(s.question_uid);
const q = qMap.get(`${t}:${n}`) || null;
const userAnswer = s.answer_data == null ? '' : String(s.answer_data);
const correct = q ? String(q.answer == null ? '' : q.answer) : null;
const orderKey = `${t}_${n}`;
const seq = rank.has(orderKey) ? rank.get(orderKey)
: (rank.has(String(n)) ? rank.get(String(n)) : null);
return {
seq,
questionType: t,
questionUid: n,
question: q ? q.question : null,
options: q ? parseOptions(q.options) : null,
// 原始值:选择 'A'/'AB';判断 'true'/'false'(由前端做「正确/错误」展示映射,后端不丢事实)
correctAnswer: correct,
maxScore: q && q.score != null ? Number(q.score) : null,
answer: userAnswer,
answered: userAnswer.trim() !== '',
isCorrect: s.is_correct == null ? null : !!s.is_correct,
score: s.score == null ? null : Number(s.score),
// 交叉校验:答案一致却判为错(或反之)即判分异常 —— 复核时最先要看的正是这个。
// ⚠️ 未作答必须给 null 而非 false:'' 与标准答案比对天然不等,
// 若返回 false,「未答」在界面上会被误读成「答错」。
answerMatches: (q && userAnswer.trim() !== '') ? (norm(userAnswer) === norm(correct)) : null,
questionMissing: !q,
};
});
objective.sort((a, b) => {
const ra = a.seq == null ? Number.MAX_SAFE_INTEGER : a.seq;
const rb = b.seq == null ? Number.MAX_SAFE_INTEGER : b.seq;
if (ra !== rb) return ra - rb;
if (a.questionType !== b.questionType) return a.questionType === 'choice' ? -1 : 1;
return a.questionUid - b.questionUid;
});
// 小数按 2 位收敛(与 DECIMAL(6,2) 及全站评分口径一致,避免 0.30000000000000004)
const round2 = v => Math.round(v * 100) / 100;
const autoScore = round2(objective.reduce((sum, o) => sum + (o.score || 0), 0));
// ---- 5) 综合大题(题干取抽题快照,保证考生当时所见可举证) ----
let bigQuestion = null;
const bigRow = await ctx.model.BigQuestionAnswer.findOne({
where: { aggregation_uid: cid },
raw: true,
});
if (bigRow) {
const snap = this.service.bigQuestion.parseSnapshot(bigRow.question_snapshot) || {};
const subPrompts = Array.isArray(snap.sub_prompts) ? snap.sub_prompts : [];
bigQuestion = {
drawn: true,
bigQuestionId: bigRow.big_question_id || null,
bigQuestionUid: bigRow.big_question_uid || null,
title: snap.title || '',
description: snap.description || '',
subPrompts,
scorePerSub: snap.score_per_sub != null ? Number(snap.score_per_sub) : null,
maxTotal: snap.total_score != null ? Number(snap.total_score) : null,
// 固定 3 个槽位,与库表 answer_1..3 对齐(未作答为 '')
answers: [ 1, 2, 3 ].map(i => {
const a = bigRow[`answer_${i}`] == null ? '' : String(bigRow[`answer_${i}`]);
const sc = bigRow[`score_${i}`];
return {
index: i,
prompt: subPrompts[i - 1] || '',
answer: a,
answered: a.trim() !== '',
score: sc == null ? null : Number(sc),
};
}),
totalScore: bigRow.total_score == null ? null : Number(bigRow.total_score),
snapshotAt: snap.snapshot_at || null,
submitTime: bigRow.submit_time || null,
};
}
return {
ok: true,
...base,
objective,
summary: {
answered: objective.length,
total: objective.length,
correctCount: objective.filter(o => o.isCorrect === true).length,
autoScore,
wordScore: contestant.word_score == null ? 0 : Number(contestant.word_score),
totalScore: round2(autoScore + (contestant.word_score == null ? 0 : Number(contestant.word_score))),
},
// 固化题序存在与否决定上图顺序的可解释性,一并上报(便于核对与排障)
questionOrderFixed: rank.size > 0,
matchedKeys: usedKeys,
bigQuestion,
works: [],
warnings,
};
}
/**
* 本科/教师组的作品文件清单(复核用,只读)
*
* 不返回 file_path(服务端磁盘路径不该出网),只给下载所需的 submission id;
* 下载走既有端点 GET /api/admin/submission-file/:group/:id。
*
* @param {number} contestantId - contestants.id
* @param {string} group - benke / jiaoshi
* @returns {Promise<Array>}
*/
async _reviewWorks(contestantId, group) {
const { ctx } = this;
const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission;
// benke/jiaoshi_submissions.aggregation_uid 存的是「聚合报名 ID」,与 contestants.id 不是同一序列,
// 需经 aggregation.account_uid 映射(与 getSubmissions 同口径)。
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: contestantId },
attributes: ['aggregation_uid'],
raw: true,
});
const keys = await resolveAggregationKeysForContestant(
ctx,
group,
contestantId,
agg ? agg.aggregation_uid : null
);
const rows = await SubmissionModel.findAll({
where: { aggregation_uid: { [ctx.model.Sequelize.Op.in]: keys.map(Number) } },
attributes: ['id', 'work_name', 'file_name', 'file_size', 'upload_time', 'score'],
order: [['upload_time', 'DESC']],
raw: true,
});
return rows.map(r => ({
id: r.id,
workName: r.work_name || '',
fileName: r.file_name || '',
fileSize: r.file_size == null ? null : Number(r.file_size),
uploadTime: r.upload_time || null,
score: r.score == null ? null : Number(r.score),
}));
}
}
module.exports = AdminService;