'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'); // 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入 // TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。 const { requireTestPassword } = require('../lib/passwordPolicy'); // R107(2026-10-12):身份证号前置校验 —— 把「非 18 位」从 Sequelize 的 // `Validation len on id_number failed` 变成可读的行级原因;同源口径见 lib/idCard.js。 const { inspectIdNumber } = require('../lib/idCard'); /** * 客观题(选择题 / 判断题)每小题固定分值。 * * 赛制口径(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']; /** * 批量解析「小组编号」标签(R178 · 成绩管理「小组编号」列) * * 需求原文:「成绩管理中,需要有小组编号来方便查证小组是否完成考试。」 * 成绩总览原本只有姓名/身份证号,无法按小组归并核对 ⇒ 补一列人类可读的小组编号。 * * 🔴 口径必须与「选手管理」一致,⛔ 不得另立第二套(对照 `getAccounts` 的 R90 段): * · 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号) * · teamUid 解析优先级:`aggregation.team_uid`(报名表聚合,权威)→ `contestants.team_id`(兜底) * · 展示值取 `teams.team_id`(人类可读队伍编号,如 BRICS2026-ST-027-91001), * 缺 `team_number` 时回落同一值 —— 与选手管理 `teamNumber` 口径相同 * * ⚠️ 为什么不能直接展示 `contestants.team_id`:该列是**内部数字**(实测王学卿=8), * 与选手管理展示的报名队伍编号不是一回事;两页显示不同值会让「按编号核对」失效。 * ⚠️ 为什么不能只查 teams 表:实测中职组张明远 `aggregation.team_uid=1` 但 teams 表 * 没有 (zhongzhi, 1) 行 —— 属「有归属无元信息」的历史数据,此时标签为 null(前端显示「未编组」)。 * * @param {Object} ctx - Egg 上下文(用 ctx.model 取模型) * @param {Array<{id:number, group:string, team_id:?number}>} contestants * @returns {Promise>} * 键 = String(contestant.id);无归属者不出现在 Map 中(调用方按 null 处理) */ async function resolveTeamLabels(ctx, contestants) { const out = new Map(); if (!Array.isArray(contestants) || contestants.length === 0) return out; const Op = ctx.model.Sequelize.Op; const ids = contestants.map(c => c.id); // 1) aggregation.team_uid(权威来源) const aggTeamUid = new Map(); const aggRows = await ctx.model.Aggregation.findAll({ where: { account_uid: { [Op.in]: ids } }, attributes: ['account_uid', 'team_uid'], raw: true, }); for (const a of aggRows) { if (a.team_uid === null || a.team_uid === undefined) continue; if (!aggTeamUid.has(a.account_uid)) aggTeamUid.set(a.account_uid, a.team_uid); } // 2) 逐人定 teamUid(aggregation 优先 → contestants.team_id 兜底) const teamUidOf = c => { if (aggTeamUid.has(c.id)) return aggTeamUid.get(c.id); return c.team_id === null || c.team_id === undefined ? null : c.team_id; }; // 3) 一次性取队伍元信息(按 (group, teamUid) 建索引,避免跨组重号串组) const uids = Array.from(new Set(contestants.map(teamUidOf).filter(u => u !== null))); const meta = new Map(); if (uids.length > 0) { const teamRows = await ctx.model.Team.findAll({ where: { team_uid: { [Op.in]: uids } }, attributes: ['team_uid', 'group_name', 'team_id', 'team_number'], raw: true, }); for (const t of teamRows) meta.set(t.group_name + '#' + t.team_uid, t); } for (const c of contestants) { const teamUid = teamUidOf(c); if (teamUid === null) continue; const m = meta.get(c.group + '#' + teamUid) || null; out.set(String(c.id), { teamUid, teamId: m ? m.team_id : null, teamNumber: m ? (m.team_number || m.team_id) : null, }); } return out; } /** * 管理后台服务 - AdminService * * 封装管理后台相关的数据库操作,包括数据统计、账号管理、Excel 导入、 * 提交记录管理、CSV 导出、批量下载 ZIP、时间/评分/题目管理等。 */ class AdminService extends Service { // ==================== 数据统计 ==================== /** * 获取数据总览统计 * 统计各组的账号数、提交数、总分、激活数 * * @returns {Promise} 综合统计数据 */ 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 isExamGroup = (group === 'zhongzhi' || group === 'gaozhi'); const SubmissionModel = isExamGroup ? ctx.model.ExamSubmission : ctx.model.WorkSubmission; const submissions = await SubmissionModel.findAll({ where: { contestant_id: contestantIds }, attributes: isExamGroup ? ['contestant_id', 'score', 'submit_time'] : ['contestant_id', 'score'], raw: true, }); // 🔴 R178(B④)仪表盘「已提交」口径修正 // 旧实现直接数 exam_submissions 的**行数**,而进考场就会建占位行(submit_time=NULL) // ⇒ 只是进过考场的人全被算成「已提交」,提交数与提交率双双虚高。 // 现在中职/高职只统计正式交卷(submit_time 非空);本科/教师仍按「上传作品即有行」。 const effective = isExamGroup ? submissions.filter(s => !!s.submit_time) : submissions; const uniqueIds = new Set(effective.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} { 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} { 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} { 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) { // 🔴 R106-D7:走唯一入口 —— 生产未显式注入 TEST_DEFAULT_PASSWORD 时**明确失败**, // ⛔ 不再静默把导入的账号批量设为内置弱口令 '123456'。 // (生产环境 importAsTest 默认 false;仅当调用方显式传 testFlag=true 才会走到这里。) defaultTestHash = await ctx.helper.hashPassword(requireTestPassword(ctx.app.config)); } // 读取 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: [], // R107:可导入但需人工复核的行(18 位但校验位不符 / 含非法字符)——非失败,不占 skipped warnings: [], }; // 取一个可用的竞赛 ID 用于聚合报名记录(aggregation.competition_uid 非空约束) let competitionUid = 1; try { // 🔴 R176:competition_info 是 key-value 表(info_key / info_value), // 不是三列结构。原实现 `findOne({ attributes:['competition_uid'] })` // 会抛 `Unknown column`,被下方 catch 吞掉后静默回落默认值 1 —— // 恰好 info_key='competition_uid' 的值也是 1,所以这个 bug 一直不可见。 const comp = await ctx.model.CompetitionInfo.findOne({ attributes: ['info_value'], where: { info_key: 'competition_uid' }, }); const parsed = comp && comp.info_value ? parseInt(comp.info_value, 10) : NaN; if (isFinite(parsed) && parsed > 0) competitionUid = parsed; } 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; } // R107(2026-10-12)必填项 + 身份证号前置校验(同样置于事务之外): // ① 长度 ≠ 18 的身份证号**永远写不进库**(model/contestant.js validate.len [18,18]), // 原实现要等 Sequelize 抛 `Validation len on id_number failed`, // 管理员只看到「跳过 N 条」而不知是哪几行、是谁 ⇒ 现改为前置拦截 + 人话原因。 // ⚠️ 与原行为**等价**:被跳过的行集合不变,仅错误信息可读性与事务开销改善。 // ② 18 位但校验位不符 / 含非法字符:模型层不校验,仍按原值导入, // 仅记入 warnings 供人工复核(生产库现存正式考生中即有此类别,硬拦截会导致漏导)。 const idNumber = pick(row, '身份证号'); const name = pick(row, '姓名'); const idCheck = inspectIdNumber(idNumber); if (!idCheck.importable) { result.errors.push({ row: rowIndex, name, message: idCheck.message }); result.skipped++; continue; } if (!name) { result.errors.push({ row: rowIndex, name: '', message: '姓名为空' }); result.skipped++; continue; } if (idCheck.level === 'warn') { result.warnings.push({ row: rowIndex, name, message: idCheck.message }); } // 整行事务:队伍 + 选手 + 聚合记录要么一起成功,要么整体回滚 const t = await ctx.model.transaction(); try { const phone = pick(row, '手机号'); const group = normalizeGroup(pick(row, '组别')); // 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁) const teamIdRaw = pick(row, '队伍编号'); // 聚合记录的身份口径与作品/答题列表保持一致(作品列表统一记为「选手」) const identity = '选手'; // R84 续六:学校 / 单位名称(上游「报名信息表」的「学校/单位名称全称」列) const schoolName = pick(row, '学校'); // 必填项(身份证号 / 姓名)已在事务之外完成前置校验(见上方 idCheck 段),此处不重复。 // 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 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; } /** * 指导教师回填(R176) * * 用户裁定:成绩公示表必须有「指导教师」列,但系统原本无任何承载字段。 * 报名表里其实有教师行(`身份` = 指导教师/教练…),只是导入时按业务规则 * 只导入「参赛选手」身份,教师行被计入 ignored 丢弃。 * * 🔴 为什么不做成「重跑一次 importContestants」: * 该流程对已存在的身份证号一律 `skipped++`,重跑不会更新任何字段, * 只是把整张表再报一遍「已存在」。故单独开一条**只写 teacher_name** 的通路。 * 🔴 为什么必须带 group:`teams.team_uid` 仅组内唯一、可跨组重号 * ⇒ 归并键 = (group, 报名表队伍编号 → teams.team_id)。 * 🔴 本方法**只**写 contestants.teacher_name,不碰账号 / 队伍 / 成绩 / 激活态。 * * @param {Buffer} buffer 报名表 xlsx 原始字节(内存解析,不落盘: * `app/public` 对静态服务开放,报名表含身份证号,落盘即可被猜名下载) * @param {Object} [options] * @param {string} [options.group] 仅回填指定组别(不传 = 表里出现的全部组别) * @param {boolean} [options.overwrite=true] false = 只填空缺,不覆盖已有值 * @returns {Promise} */ async backfillTeachers(buffer, options = {}) { const { ctx } = this; const teacherBackfill = require('../lib/teacherBackfill'); const { Op } = ctx.app.Sequelize; if (!buffer || !buffer.length) { return { ok: false, code: 'EMPTY_BUFFER', message: '未收到文件内容' }; } let aoa; try { const workbook = XLSX.read(buffer, { type: 'buffer' }); const sheetName = workbook.SheetNames[0]; if (!sheetName) { return { ok: false, code: 'EMPTY_FILE', message: '文件里没有工作表' }; } aoa = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { header: 1, blankrows: true }); } catch (e) { return { ok: false, code: 'PARSE_FAILED', message: `无法解析为 Excel:${e.message}` }; } const parsed = teacherBackfill.extractTeachers(aoa); if (!parsed.ok) { return { ok: false, code: parsed.code, message: parsed.message, stats: parsed.stats, warnings: parsed.errors, }; } // 可选:只处理指定组别(管理端按组操作时避免误改其他组) const onlyGroup = options.group ? String(options.group) : ''; const targets = onlyGroup ? parsed.teams.filter(t => t.group === onlyGroup) : parsed.teams; if (targets.length === 0) { return { ok: false, code: 'NO_TEACHER_IN_GROUP', message: onlyGroup ? `报名表里没有属于「${onlyGroup}」组的指导教师行` : '没有可回填的小组', stats: parsed.stats, warnings: parsed.errors, }; } const overwrite = options.overwrite !== false; // 一次性取回本次涉及组别的队伍映射:key = group\u0000team_id const groupSet = Array.from(new Set(targets.map(t => t.group))); const teamRows = await ctx.model.Team.findAll({ attributes: ['team_uid', 'team_id', 'group_name'], where: { group_name: { [Op.in]: groupSet } }, raw: true, }); const teamKeyMap = new Map(); for (const t of teamRows) { if (t.team_uid === null || t.team_uid === undefined) continue; teamKeyMap.set(`${t.group_name}\u0000${t.team_id}`, t.team_uid); } const details = []; const unmatched = []; let matchedTeams = 0; let updatedRows = 0; let unchangedRows = 0; let noMemberTeams = 0; for (const t of targets) { const teamUid = teamKeyMap.get(`${t.group}\u0000${t.teamId}`); if (teamUid === undefined) { unmatched.push({ group: t.group, teamId: t.teamId, reason: '本组 teams 表里没有该队伍编号(可能尚未导入选手,或编号写法不一致)', }); continue; } matchedTeams++; const teacherText = teacherBackfill.joinNames(t.names); const members = await ctx.model.Contestant.findAll({ attributes: ['id', 'name', 'teacher_name'], where: { team_id: teamUid, group: t.group }, raw: true, }); if (members.length === 0) { noMemberTeams++; details.push({ group: t.group, teamId: t.teamId, teamUid, teacherName: teacherText, memberCount: 0, affected: 0, note: '该队伍在 contestants 里没有成员', }); continue; } // 只更新「需要变」的行:overwrite=false 时仅填空缺 const needIds = []; for (const m of members) { const current = m.teacher_name === null || m.teacher_name === undefined ? '' : String(m.teacher_name); if (!overwrite && current !== '') { unchangedRows++; continue; } if (current === teacherText) { unchangedRows++; continue; } needIds.push(m.id); } if (needIds.length > 0) { // 🔴 只写 teacher_name 一列:显式 attributes 白名单,避免把整行对象 // 交给 update 时顺手带回 score / word_score 等无关列(Sequelize 会只更新 // 传入的键,但显式白名单能让后续 review 一眼看出写入面)。 await ctx.model.Contestant.update( { teacher_name: teacherText }, { where: { id: { [Op.in]: needIds } } } ); updatedRows += needIds.length; } details.push({ group: t.group, teamId: t.teamId, teamUid, teacherName: teacherText, memberCount: members.length, affected: needIds.length, rowNumbers: t.rowNumbers, }); } return { ok: true, code: 'OK', message: `已回填 ${updatedRows} 名选手的指导教师(命中 ${matchedTeams} 个小组)`, stats: parsed.stats, teams: { total: targets.length, matched: matchedTeams, unmatched: unmatched.length, noMember: noMemberTeams, }, updated: updatedRows, unchanged: unchangedRows, details, unmatched, // 教师行本身的问题(缺组别 / 缺队伍编号 / 姓名为空)——非致命,逐行透出 warnings: parsed.errors, }; } // ==================== 提交记录管理 ==================== /** * 获取考试答题记录(查询组别对应的提交表) * @param {string} group - 组别 * @returns {Promise} 答题记录 */ 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 }, // R178:补 team_id —— 后台需要「小组编号」核对某小组是否整体完成考试。 // 组的归属只认 contestants.team_id(= teams.team_uid),⛔ 不可回落到 // aggregation.team_uid(生产实测两者不同号,会把不同小组混成一个)。 attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'team_id'], 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; }); // 🔴 R166 修复「已答 N / N 题」恒真显示:原 `totalQuestions: answeredCount` // ⇒ 分子分母恒相等,该列零信息量(连「只答了 3 题」的考生也显示「已答 3 / 3 题」)。 // 真实分母 = 该考生被抽到的**总题数**,口径必须与抽题实现(service/exam.js:85-101)同源: // 实抽 = min(配置抽取数, 题库实际容量) 逐题型相加 // ⚠️ 为什么不能直接用 choice_count + judge_count:那是**上限**, // 题库不足时 `limit` 会被封顶(controller/exam.js:263-266 的 available* 就是为此暴露), // 直接相加会把「实际 40 题」显示成「70 题」⇒ 分母大于真实题数,比恒真更误导。 // ⚠️ 为什么不能从逐题表反推总数:`subs.length` 只统计**已答**行, // 未答的抽题不入表 ⇒ 反推出来仍是 answeredCount(同恒真)。 const [timeRow, availableChoice, availableJudge] = await Promise.all([ ctx.model.CompetitionTime.findOne({ where: { group }, attributes: ['choice_count', 'judge_count'], raw: true, }), ctx.model.ChoiceQuestion.count({ where: { group } }), ctx.model.JudgeQuestion.count({ where: { group } }), ]); const totalQuestions = Math.min(timeRow && timeRow.choice_count != null ? timeRow.choice_count : 50, availableChoice) + Math.min(timeRow && timeRow.judge_count != null ? timeRow.judge_count : 20, availableJudge); 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); // 🔴 R178(B④)「已提交」口径拆分 // // 旧写法:`submitted: !!formalSubmitTime || answeredCount > 0` // ⇒ 只要答过一题就算「已提交」,没点交卷的考生在后台也显示「已提交」, // 管理员无法据此判断谁真的交卷了;`submitTime` 还会退回首条答题行的 // created_at,把「第一次答题时间」显示成「提交时间」。 // 现在:submitted / formalSubmitted = 是否**正式交卷**,唯一判据 = exam_submissions.submit_time // (与选手端 hasSubmitted 同一判定源);作答进度由 answeredCount / totalQuestions 单独表达。 const formalSubmitTime = (wordSub && wordSub.submit_time) || null; const formalSubmitted = !!formalSubmitTime; const firstAnswerAt = subs.length ? subs[0].created_at : 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, // 小组编号(teams.team_uid);为空表示该选手未编入小组(导入时缺小队列的历史数据) teamId: c.team_id === null || c.team_id === undefined ? null : String(c.team_id), answeredCount, totalQuestions, 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: formalSubmitted, formalSubmitted, firstAnswerAt, submitTime: formalSubmitTime, // 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致。 // ⚠️ 此前本行与上方 `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} [{ 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>}>} */ async buildScoreExportTable(group) { const isExam = group === 'zhongzhi' || group === 'gaozhi'; if (isExam) { // 中职/高职组:客观题(系统自动评分) + 综合大题(人工阅卷) // 说明:大题分落库列名为 contestants.word_score(历史沿用,语义已变为「综合大题得分」), // 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。 const submissions = await this.getExamSubmissions(group); return { // 🔴 R178(B④):原「是否提交」列取 `answeredCount > 0`,与页面/接口口径(submit_time) // 不一致,同一份数据导出与页面显示会互相矛盾。现在拆为「是否交卷」(submit_time) // 与「答题数」两列,口径与 getExamSubmissions 的 submitted 完全同源。 headers: ['选手ID', '身份证号', '姓名', '手机号', '是否交卷', '答题数', '客观题分', '大题分', '总分'], rows: submissions.map(item => [ item.contestantId, item.idNumber || '', item.userName || '', item.phone || '', item.submitted ? '是' : '否', 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} 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} 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' }); } /** * 成绩公示表导出(R176) * * 产物 = 赛事官方参考模板(`lib/assets/publicity-template.xlsx`)的 xlsx 半成品: * 数据行已填,**获奖等级留空**待人工填。 * * 行粒度(用户裁定):**一行 = 一个小组**。系统规则是「一组一人参赛、成绩全组共享」, * 所以同一小组不会因为多人而占多行;`参赛选手` / `指导教师` 两列用「、」连接全体。 * 没有队伍归属的选手(`team_id IS NULL`,单人参赛)各自一行。 * * 🔴 `contestants.score` 只在「组内唯一抽题者」名下非空(见 score.js 的共享读), * 因此按组取 **MAX(score)**,⛔ 不能取第一行 —— 否则会取到 NULL。 * 🔴 单位取 `aggregation.organization_cn`(`account_uid` = 选手 id), * 组内取首个非空值(同队理论上相同)。 * 🔴 本表**不含**身份证号 / 手机号,可直接对外;内部核查请用 exportToXlsx。 * * @param {string} group * @returns {Promise<{buffer: Buffer, fileName: string, rowCount: number, testCount: number, title: Object}>} */ async buildPublicityXlsx(group) { const { ctx } = this; const publicitySheet = require('../lib/publicitySheet'); const fs = require('fs'); const path = require('path'); const { Op } = ctx.app.Sequelize; const GROUP_LABEL = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }; const groupLabel = GROUP_LABEL[group] || group; // ---- 1) 赛项名称 / 编号(管理后台「赛项设置」维护的两项)---- let itemName = ''; let itemCode = ''; try { const cfgRows = await ctx.model.CompetitionInfo.findAll({ attributes: ['info_key', 'info_value'], where: { info_key: { [Op.in]: ['competition_item_name', 'competition_item_code'] } }, raw: true, }); for (const r of cfgRows) { const v = r.info_value === null || r.info_value === undefined ? '' : String(r.info_value).trim(); if (r.info_key === 'competition_item_name') itemName = v; if (r.info_key === 'competition_item_code') itemCode = v; } } catch (e) { // 配置缺失不应阻断导出:标题保持模板占位(***赛项 / 空编号),人工再补 ctx.logger.warn('[publicity] 读取赛项配置失败,标题使用模板占位:%s', e.message); } // ---- 2) 选手(⛔ 不筛 test_flag:与 exportToXlsx 口径一致;测试行数单独统计给前端提示)---- const contestants = await ctx.model.Contestant.findAll({ attributes: ['id', 'name', 'score', 'team_id', 'teacher_name', 'test_flag'], where: { group }, raw: true, }); // ---- 3) 队伍编号(报名表里的「队伍编号」落在 teams.team_id)---- const teams = await ctx.model.Team.findAll({ attributes: ['team_uid', 'team_id'], where: { group_name: group }, raw: true, }); const teamIdByUid = new Map(); for (const t of teams) { if (t.team_uid !== null && t.team_uid !== undefined) teamIdByUid.set(t.team_uid, t.team_id); } // ---- 4) 参赛单位 ---- const aggRows = await ctx.model.Aggregation.findAll({ attributes: ['account_uid', 'organization_cn'], where: { group }, raw: true, }); const orgByCid = new Map(); for (const a of aggRows) { const v = a.organization_cn === null || a.organization_cn === undefined ? '' : String(a.organization_cn).trim(); if (v && a.account_uid !== null && a.account_uid !== undefined) orgByCid.set(a.account_uid, v); } // ---- 5) 按小组聚合 ---- const bucket = new Map(); let testCount = 0; let noTeamCount = 0; for (const c of contestants) { if (c.test_flag === 1) testCount++; const hasTeam = c.team_id !== null && c.team_id !== undefined; // 🔴 组归属只认 contestants.team_id(= teams.team_uid)——这是系统里唯一可靠的键。 // ⛔ 不要回落到 aggregation.team_uid:那是另一套编号(实测本地 zhongzhi 的 // aggregation.team_uid=1 而 teams.team_uid=8),拿它归组会把不同队伍并到一起。 // 无 team_id 的选手按「单人参赛」各自成行,并把人数透出给前端提示 // —— 覆盖率不足是数据问题,⛔ 不能在导出时静默猜。 if (!hasTeam) noTeamCount++; const key = hasTeam ? `team:${c.team_id}` : `single:${c.id}`; let item = bucket.get(key); if (!item) { item = { teamUid: hasTeam ? c.team_id : null, teamId: hasTeam ? String(teamIdByUid.get(c.team_id) || c.team_id) : '', org: '', players: [], teachers: [], score: null, }; bucket.set(key, item); } if (c.name) item.players.push(String(c.name).trim()); // teacher_name 是「、」连接的整串,拆开交给 buildPublicityRows 统一去重拼接 if (c.teacher_name) { for (const n of String(c.teacher_name).split('、')) { const s = n.trim(); if (s) item.teachers.push(s); } } if (c.score !== null && c.score !== undefined) { const s = Number(c.score); if (isFinite(s) && (item.score === null || s > item.score)) item.score = s; } const org = orgByCid.get(c.id); if (org && !item.org) item.org = org; } const rows = publicitySheet.buildPublicityRows(Array.from(bucket.values())); // ---- 6) 套模板 ---- const tplPath = path.join(__dirname, '../lib/assets/publicity-template.xlsx'); const template = fs.readFileSync(tplPath); const built = publicitySheet.buildXlsx(template, rows, { itemName, itemCode }); const now = new Date(); const pad = n => String(n).padStart(2, '0'); const dateTag = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`; return { buffer: built.buffer, fileName: `成绩公示表-${groupLabel}-${dateTag}.xlsx`, rowCount: rows.length, testCount, noTeamCount, totalContestants: contestants.length, title: built.title, // 赛项名/编号为空时前端要提示「标题仍是模板占位」 itemName, itemCode, }; } // ==================== 赛项信息配置(R176) ==================== /** 成绩公示表标题要用的两项配置。读:`competition_info` 的 key-value 行 */ async getCompetitionItemInfo() { const { ctx } = this; const { Op } = ctx.app.Sequelize; const out = { itemName: '', itemCode: '' }; const rows = await ctx.model.CompetitionInfo.findAll({ attributes: ['info_key', 'info_value'], where: { info_key: { [Op.in]: ['competition_item_name', 'competition_item_code'] } }, raw: true, }); for (const r of rows) { const v = r.info_value === null || r.info_value === undefined ? '' : String(r.info_value); if (r.info_key === 'competition_item_name') out.itemName = v; if (r.info_key === 'competition_item_code') out.itemCode = v; } return out; } /** * 保存赛项信息。 * 🔴 用 upsert 语义而非「先删后插」:`competition_info` 的 id 是自增主键, * 删了重建会让 id 漂移,且若中途失败会留下半张表。 * @param {{itemName?: string, itemCode?: string}} payload */ async setCompetitionItemInfo(payload = {}) { const { ctx } = this; const pairs = [ ['competition_item_name', payload.itemName], ['competition_item_code', payload.itemCode], ]; const saved = {}; for (const [key, raw] of pairs) { if (raw === undefined) continue; // 未提交的键不动 const value = raw === null ? '' : String(raw).trim(); const exist = await ctx.model.CompetitionInfo.findOne({ where: { info_key: key } }); if (exist) { await exist.update({ info_value: value }); } else { await ctx.model.CompetitionInfo.create({ info_key: key, info_value: value }); } saved[key] = value; } return saved; } // ==================== 批量下载 ZIP ==================== /** * 批量下载某组所有提交文件,打包为 ZIP * 文件重命名为 "组号-学校-姓名-原文件名" 格式 * * @param {string} group - 组别 * @returns {Promise} 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} */ 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} 创建的题目 */ 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} 删除行数 */ 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, }); // ---- 节点备份重排(考试开始前 30 分钟 / 考试结束后 10 分钟)---- // 🔴 R167:管理员每次保存/修改考试时间都要重排该组的两个时间节点。 // · 幂等键含 fireAt ⇒ 改时间 = 新计划(该再备一次);重复点保存同一时间 // = 同一 fireAt = 覆盖同一计划(不重复触发)。 // · ⛔ 备份失败绝不影响本方法返回 —— 定时器是「到点才跑」,此处只排程, // 且调度器内部对 enabled=false 直接返回(非生产不排)。 try { const scheduler = this.app.nodeBackupScheduler; if (scheduler && scheduler.enabled) { scheduler.rescheduleGroupTimes([{ group, start_time: startTime ? new Date(startTime) : null, end_time: endTime ? new Date(endTime) : null, }]); } } catch (bkErr) { this.ctx.logger.error( '[setTimes] 节点备份重排失败(不影响时间设置):group=%s err=%s', group, bkErr && bkErr.message ); } } } // ==================== 时间查询 ==================== /** * 获取所有组的比赛时间配置 * @returns {Promise} 时间配置列表 */ 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} 评分列表 */ 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', 'team_id'], 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, })); // R178:小组编号(与「选手管理」同源口径,见模块级 resolveTeamLabels 注释)。 // 前端按 teamId 渲染;teamId 为 null ≠ 「没有小组」的权威判据,仅表示「无队伍元信息」, // 因此另附 teamUid 便于排查「有归属但缺 teams 行」的历史数据。 const teamLabels = await resolveTeamLabels(ctx, contestants); list.forEach(item => { const t = teamLabels.get(String(item.uid)) || null; item.teamUid = t ? t.teamUid : null; item.teamId = t ? t.teamId : null; item.teamNumber = t ? t.teamNumber : null; }); 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); // 🔴 R178(B④)与「考生答题记录」同一口径:submitted = 是否**正式交卷** //(唯一判据 exam_submissions.submit_time);作答进度看 answeredCount。 // ⛔ 不再用 es.created_at(进考场建占位行的时间)冒充 submitTime。 item.formalSubmitted = !!(es && es.submit_time); item.submitted = item.formalSubmitted; item.submitTime = (es && es.submit_time) || null; item.firstAnswerAt = 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} { 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} 受影响行数 */ 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} 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' }); } // R177:删掉此处一个**死代码版** `listNews()` —— 它无分页、缺 is_draft/附件字段、 // 排序按 sort_order,且在同一个 class 内被下方同名方法覆盖(JS class 后定义胜出)⇒ 永远不执行。 // ⚠️ 新闻列表的唯一生效实现 = 本文件下方的 `async listNews(query)`(分页 + 含 is_draft/附件字段)。 // 保留这行说明是为了防止后人「以为这里还有一份实现」而改到静默无效的位置。 // ==================== 轮播图查询 ==================== /** * 获取所有轮播图列表 * @returns {Promise} 轮播图列表 */ 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_`(仅新增 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); } // ---- 节点备份:成绩「发布」后立即触发(撤销不触发)---- // ⚠️ 用户裁定③:撤销发布时备份到的是**已发布过的数据**,对回退诉求无意义 ⇒ // 只在 publish === true 时触发。⛔ 必须排在事务之外(此处 transaction 已提交), // 否则备份 dump 到的可能是未提交快照。 // ⛔ 备份失败不得让「发布成绩」这个管理动作失败 ⇒ 独立 try/catch 只记日志。 if (publish) { try { const scheduler = this.app.nodeBackupScheduler; if (scheduler && scheduler.enabled) { // ⛔ await 但内部为 spawn 异步 + 不抛错 ⇒ 只多花一次 spawn 的时间 scheduler.triggerScorePublished(group).catch(bkErr => { this.ctx.logger.error('[publishScores] 节点备份触发失败:%s', bkErr && bkErr.message); }); } } catch (bkErr) { this.ctx.logger.error('[publishScores] 节点备份触发失败(不影响发布):%s', bkErr && bkErr.message); } // ---- 成绩公示预览新闻(R176)---- // 用户裁定:成绩发布后直接生成「包含附件的预览新闻」,等人工编辑补齐获奖等级后再发布。 // 🔴 必须是**草稿**(is_draft=1):news.list / findById 都会过滤 is_draft=1, // 否则未定稿的成绩会立刻出现在前台新闻列表 ⇒ 等于提前公示错误成绩。 // ⛔ 与节点备份同理:生成失败不得让「发布成绩」这个动作失败。 try { const targets = group ? [ group ] : this.constructor.PUBLISH_GROUPS; await this._ensurePublicityDraft(targets); } catch (pubErr) { this.ctx.logger.error('[publishScores] 生成公示预览新闻失败(不影响发布):%s', pubErr && pubErr.message); } } } /** * 为指定组别生成「成绩公示」草稿新闻(R176)。 * * 幂等:同组已有草稿(tags = 'publicity-draft' 且 is_draft=1)时**覆盖**其附件与正文, * 不新增第二条 —— 反复发布/撤销再发布会堆出一串重复新闻。 * 覆盖时旧附件文件要一并清理,否则 `app/public/news` 会随发布次数线性膨胀。 * * @param {string[]} groups * @returns {Promise>} 每组的结果摘要(仅供日志/排查) */ async _ensurePublicityDraft(groups) { const { ctx } = this; const fs = require('fs'); const path = require('path'); const GROUP_LABEL = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }; const DRAFT_TAG = 'publicity-draft'; const results = []; for (const g of groups) { try { const built = await this.buildPublicityXlsx(g); const dir = path.join(this.app.baseDir, 'app/public/news'); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const storedName = `news_file_publicity_${g}_${Date.now()}.xlsx`; fs.writeFileSync(path.join(dir, storedName), built.buffer); const fileUrl = `/public/news/${storedName}`; const label = GROUP_LABEL[g] || g; const title = `${label}成绩公示(待审核)`; const content = [ `

本公示表按成绩管理数据自动生成,尚未定稿,请核对数据并补齐「获奖等级」后再对外发布。

`, '

填表说明:一行代表一个参赛小组;「参赛选手」「指导教师」列多人时以顿号分隔;', '总分数按由高到低排序,「成绩排名」同序排列。

', `

本次导出共 ${built.rowCount} 个小组${built.testCount ? `(其中 ${built.testCount} 人属于测试数据,发布前请确认是否需要剔除)` : ''}。

`, ].join(''); const exist = await ctx.model.News.findOne({ where: { group: g, is_draft: 1, tags: DRAFT_TAG } }); if (exist) { const oldUrl = exist.attachment_url; await exist.update({ title, content, news_type: 'content', attachment_url: fileUrl, attachment_name: built.fileName, }); // 旧附件清理:只删本流程自己产出的文件(前缀 + 目录双重限定,绝不误删人工上传的图/文件) if (oldUrl && oldUrl !== fileUrl && oldUrl.indexOf('/public/news/news_file_publicity_') === 0) { ctx.helper.safeCleanup(path.join(this.app.baseDir, 'app', oldUrl.replace(/^\//, '')), { label: 'publicity-draft-old', }); } results.push({ group: g, newsId: exist.id, action: 'updated', fileUrl }); } else { const created = await this.createNews({ title, content, news_type: 'content', group: g, tags: DRAFT_TAG, is_draft: 1, publish_time: new Date(), attachment_url: fileUrl, attachment_name: built.fileName, }); results.push({ group: g, newsId: created.id, action: 'created', fileUrl }); } } catch (e) { ctx.logger.error('[publicity] 组 %s 生成公示预览失败:%s', g, e.message); results.push({ group: g, action: 'error', message: e.message }); } } return results; } /** * 检查成绩是否已发布 * @param {string} [group] - 组别;缺省时要求「全部组均已发布」才返回 true * @returns {Promise} 严格布尔值 */ 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} { 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} */ 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, // R176:草稿标记与附件。缺省 is_draft=0(已发布)保持既有调用方行为不变; // 成绩发布自动生成的预览新闻会显式传 1,人工编辑确认后再置 0。 is_draft: newsData.is_draft ? 1 : 0, attachment_url: newsData.attachment_url || null, attachment_name: newsData.attachment_name || 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, // R176:附件与草稿。人工在新闻管理里「发布」= 把 is_draft 置 0。 is_draft: newsData.is_draft !== undefined ? (newsData.is_draft ? 1 : 0) : news.is_draft, attachment_url: newsData.attachment_url !== undefined ? (newsData.attachment_url || null) : news.attachment_url, attachment_name: newsData.attachment_name !== undefined ? (newsData.attachment_name || null) : news.attachment_name, }); return news; } /** * 删除新闻 * @param {number} id - 新闻 ID */ async deleteNews(id) { const { ctx } = this; await ctx.model.News.destroy({ where: { id } }); } /** * 获取新闻列表(管理端) * @param {Object} query - 查询参数 * @returns {Promise} */ 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} */ 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} */ 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} { 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} */ 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), })); } /** * 编辑选手信息(R108 新增) * * 背景:此前 `contestants` 只有「Excel 导入」一个写入口 —— 身份证号 / 姓名 / 手机号 * 一旦录错就**没有任何退路**(既不能改也不能删),只能造新行或直接改库。 * 本方法把「已入库选手的身份字段」变成可维护的,用于事后纠正录入错误 * (典型场景:报名表里把证号写错位,核实后再修正)。 * * ── 可改字段(白名单,其余字段一律**拒绝**而非静默忽略)── * · name 姓名 * · phone 手机号 * · id_number 身份证号(同时是**登录账号**,见 service/auth.js#findByAccount) * * ⚠️ 为什么白名单之外要「报错」而不是「忽略」:静默忽略会让管理员以为改组别 * 成功了,实际没改 —— 属于最难排查的一类故障。此处宁可显式打回。 * * ── 显式**不可改**的字段及理由 ── * · group 组别 —— 主观题按组别抽题(big_questions / choice_questions)、 * 成绩按组别发布(score_published_)。改组别会让「已作答的 * 答卷」与「新组别的题目、新组别的发布开关」错配。 * 确需换组请走「重新导入」并另行处置其提交记录。 * · score / word_score / ppt_score / exam_score * 成绩字段 —— 唯一写入口是成绩发布(service.admin.updateScores)。 * 本接口一旦可写即为**越权改分通道**。 * · password 口令 —— 不接受前端直接传值;改号时的连带重算见下方「5.2」。 * · test_flag / team_id / activated * 另有专用接口与流程。混入本接口会造成越权(例如把生产数据一键标成 * 测试数据,随后被 resetTestData 清空 —— 那是不可逆的)。 * * ── 身份证号变更的两个连带动作 ── * 5.1 唯一性:`contestants.id_number` 有 UNIQUE 约束 ⇒ 命中他人即拒绝(409)。 * 5.2 口令同步:导入通道的口令派生规则是 * · 测试数据(importAsTest)→ 统一默认口令的 bcrypt 哈希(与身份证号无关) * · 生产数据 → sha256(身份证号**后 6 位**) * 身份证号是登录账号 ⇒ 生产数据改号后若不同步重算口令,选手用新号登录会 * 「账号对、口令错」。故:未激活且仍是派生口令 ⇒ 重算;已激活(口令由本人 * 设置)⇒ **不动**并给出 warning。 * * 入库值口径:沿用导入通道 —— **去首尾空白后的原样字符串,不强制大写**。 * 理由:与既有数据、与前端登录入参三者字面一致;MySQL 默认 *_ci 排序规则已使 * 'x' / 'X' 同值,唯一约束与判重均不受大小写影响。(末位小写 x 会由 * lib/idCard.js 报 warn「含非法字符」,从而进入 warnings 供人工复核。) * * @param {number|string} id - contestants.id * @param {Object} payload - 仅接受 { name?, phone?, id_number? } * @returns {Promise} * · 成功 `{ ok: true, changed: string[], warnings: string[], contestant: Object }` * (`changed` 为空数组 = 无可改内容,未落库、未写审计) * · 失败 `{ ok: false, code, message }`,code ∈ * BAD_ID / NOT_FOUND / FIELD_NOT_ALLOWED / INVALID / DUPLICATE_ID */ async updateContestant(id, payload = {}) { const { ctx } = this; // ---- 0) 定位选手 ---- const cid = Number(id); if (!Number.isInteger(cid) || cid <= 0) { return { ok: false, code: 'BAD_ID', message: '选手 ID 非法' }; } const contestant = await ctx.model.Contestant.findByPk(cid); if (!contestant) { return { ok: false, code: 'NOT_FOUND', message: '选手不存在' }; } // ---- 1) 字段准入(白名单)---- // `undefined` / `null` 视为「本次未提交该字段」;已提交但不在白名单 ⇒ 直接拒绝。 const ALLOWED = ['name', 'phone', 'id_number']; const body = (payload && typeof payload === 'object') ? payload : {}; const submitted = Object.keys(body).filter(k => body[k] !== undefined && body[k] !== null); const rejected = submitted.filter(k => !ALLOWED.includes(k)); if (rejected.length > 0) { return { ok: false, code: 'FIELD_NOT_ALLOWED', message: `本接口不允许修改字段:${rejected.join('、')}(仅支持 姓名 / 手机号 / 身份证号)`, }; } const warnings = []; const updates = {}; const changed = []; // ---- 2) 姓名 ---- if (submitted.includes('name')) { const name = String(body.name).trim(); if (!name) { return { ok: false, code: 'INVALID', message: '姓名不能为空' }; } if (name.length > 50) { return { ok: false, code: 'INVALID', message: `姓名不能超过 50 个字符(当前 ${name.length} 个)`, }; } if (name !== (contestant.name || '')) { updates.name = name; changed.push('name'); } } // ---- 3) 手机号 ---- if (submitted.includes('phone')) { const phone = String(body.phone).trim(); // 允许留空:导入通道亦允许缺手机号(落库为 ''),不能因为「本来就没填」而卡住编辑 if (phone && !/^1\d{10}$/.test(phone)) { return { ok: false, code: 'INVALID', message: `手机号格式不正确(应为 1 开头的 11 位数字,当前「${phone}」)`, }; } if (phone !== (contestant.phone || '')) { updates.phone = phone; changed.push('phone'); } } // ---- 4) 身份证号 ---- let idChanged = false; if (submitted.includes('id_number')) { const nextId = String(body.id_number).trim(); // 与导入通道**同源判据**(lib/idCard.js):长度 ≠ 18 拦截;校验位不符 / 含非法字符 // 仅告警不阻断 —— 生产库现存正式考生中即有校验位不符者,硬拦截会造成合法数据改不动。 const idCheck = inspectIdNumber(nextId); if (!idCheck.importable) { return { ok: false, code: 'INVALID', message: idCheck.message }; } if (idCheck.level === 'warn') { warnings.push(idCheck.message); } // 大小写不敏感比较:'x' / 'X' 在库内本就同值(*_ci 排序规则), // 不应因大小写差异触发一次无意义的写库 + 口令重算。 if (nextId.toLowerCase() !== String(contestant.id_number || '').toLowerCase()) { idChanged = true; updates.id_number = nextId; changed.push('id_number'); } } // ---- 5) 身份证号变更的连带处理 ---- if (idChanged) { // 5.1 唯一性冲突 const dup = await ctx.model.Contestant.findOne({ where: { id_number: updates.id_number, id: { [ctx.model.Sequelize.Op.ne]: cid }, }, attributes: ['id', 'name', 'group', 'test_flag'], raw: true, }); if (dup) { const flag = Number(dup.test_flag) === 1 ? '(测试数据)' : ''; return { ok: false, code: 'DUPLICATE_ID', message: `身份证号 ${updates.id_number} 已被占用:${dup.name}${flag} / id=${dup.id} / 组别=${dup.group}`, }; } // 5.2 口令同步(口径见方法头注释) const currentPw = contestant.password || ''; if (Number(contestant.test_flag) === 1) { // 测试数据的口令是统一默认口令(bcrypt,与身份证号无关)⇒ 改号不影响登录,无需重算 } else if (contestant.activated) { warnings.push('该选手已激活(口令由本人设置),身份证号变更后**原口令保持不变**;如需以新号码登录,请让选手使用「忘记密码」重设。'); } else if (ctx.helper.isBcryptHash(currentPw)) { warnings.push('该选手尚未激活,但口令已是加盐哈希(并非「证号后 6 位」的派生值),本次不予覆盖;如需以新号码登录,请让选手使用「忘记密码」重设。'); } else { updates.password = ctx.helper.sha256(updates.id_number.slice(-6)); changed.push('password'); } } // ---- 6) 无实质变更:不落库、不写审计(避免噪音,也让前端能区分「改了」与「没改」)---- const project = () => ({ id: contestant.id, name: contestant.name || '', phone: contestant.phone || '', // 管理端选手列表本就明文展示身份证号,此处保持同口径(不下发 password) id_number: contestant.id_number || '', group: contestant.group, team_id: contestant.team_id == null ? null : Number(contestant.team_id), activated: !!contestant.activated, test_flag: Number(contestant.test_flag), }); if (changed.length === 0) { return { ok: true, changed: [], warnings, contestant: project() }; } // ---- 7) 落库(审计需 before/after,故先留快照)---- const before = { name: contestant.name || '', phone: contestant.phone || '', id_number: contestant.id_number || '', }; await contestant.update(updates); // ---- 8) 审计日志(身份证号脱敏;password 只记「是否重算」,绝不落值)---- const mask = v => { const s = String(v == null ? '' : v); if (!s) return ''; return s.length >= 10 ? `${s.slice(0, 6)}${'*'.repeat(s.length - 10)}${s.slice(-4)}` : '***'; }; ctx.logger.info( '[updateContestant] cid=%d 字段=%j before=%j after=%j 口令重算=%s', cid, changed, { name: before.name, phone: before.phone, id_number: mask(before.id_number) }, { name: contestant.name || '', phone: contestant.phone || '', id_number: mask(contestant.id_number), }, changed.includes('password') ? 'yes' : 'no' ); return { ok: true, changed, warnings, contestant: project() }; } } module.exports = AdminService;