Files
competition-website/app/service/admin.js
qubiaobiao 0a380371ec feat(upload): 组内作品唯一 + 小组编号目录 + 按小组打包下载(R190)
需求(管理人员反馈):
① 本科组/教师组同样遵循「小组内只要有一人上传作品,其他人就不能再上传」;
② 作品按「小组编号」建目录存放并保留原始文件名,管理端下载时按小组打包。

后端落地:
- 组锁:新增 getTeamUploadState/assertTeamUploadAllowed,按 (group, teamUid) 取全组
  提交记录,**最早提交者为归属人**;其他人上传与分片合并两条路径都返 6006
  (data 带 teamLocked/teamOwnerName/teamNumber/folderName)。删除本人作品后自动解锁。
  ⛔ 必须先按 group 过滤再认 teamUid(team_uid 仅组内唯一)。
- 落盘:works/<group>/<小组编号>/<原始文件名>。目录名走新增 lib/team_folder.js
  (唯一 SoT:净化 Windows 非法字符/控制字符/首尾点/设备保留名,限长 60)。
  撞名三态:不存在→原名;同一聚合+同一 file_type→原名原子覆盖;否则 (2)(3)。
  DB file_path 仍为相对路径 ⇒ 存量平铺文件免迁移。
- 直传与分片合并收敛到同一套 prepareWorkTarget + commitWorkFile:先写同目录临时名
  (.upload_*/.merge_*),再 renameSync;失败一律 safeCleanup 后抛。
- status 接口新增 teamLocked/canUpload/teamOwnerName/isTeamOwner/teamFiles/memberCount。
- 管理端:works 列表返回 teamUid/teamNumber/teamFolder(走 resolveTeamLabels 既有口径);
  新增 GET /api/admin/team-zip/:group?teamUid= 单组打包(仅本科/教师,其余 400,包内平铺);
  batch-download 包内改为 <小组编号>/<原始文件名>。两者共用 _buildZip。

验证:真接口 45/45 + 变异 M1(locked=false ⇒ 15 条断言红,还原后复绿);
已收编为门禁第 58 步。
2026-10-21 15:20:42 +08:00

3726 lines
158 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const Service = require('egg').Service;
const path = require('path');
const fs = require('fs');
// 2026-10-10 换库:理由同 service/bigQuestion.js —— npm 上 `xlsx` 冻结在 0.18.5(两处 high CVE),
// 官方修复版仅在不可达的 cdn.sheetjs.com ⇒ 改用零依赖、API 一致的 `@e965/xlsx@0.20.3`。
const XLSX = require('@e965/xlsx');
const {
buildAggregationKeyMap,
resolveAggregationKeysForContestant,
} = require('../lib/aggregationKey');
// 🔴 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');
// 🔴 R190:小组作品目录命名的**唯一 SoT**。上传落盘(service/upload.js)与本文件的
// 批量/单组打包**必须**用同一个函数算目录名,否则「上传写进 A、下载从 B 找」。
const {
resolveTeamFolderName,
teamFolderRelative,
} = require('../lib/team_folder');
/**
* 客观题(选择题 / 判断题)每小题固定分值。
*
* 赛制口径(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'];
/**
* 时间配置并发冲突(B② 乐观锁)。
*
* 场景:两个管理员同时打开「时间设置」,甲先保存,乙手里的表单已过期;乙再保存
* 就会**静默覆盖**甲的结果。此前无任何检测 —— `setTimes` 直接 upsert。
*
* 判据选「值级比对」而非时间戳:`competition_times.updated_at` 没有
* `ON UPDATE CURRENT_TIMESTAMP`,其刷新完全依赖 Sequelize upsert 对 default
* 字面量的处理,语义不确定 ⇒ 建在它上面等于建在流沙上。值级比对只看
* 「客户端读到的 start/end/deadline」与「库里现值」是否一致,确定、可测、零 DDL。
*
* ⚠️ 只在**调用方显式传 expected** 时启用:不传 ⇒ 退回旧行为,不破坏既有调用方。
*/
class TimesConflictError extends Error {
constructor(conflicts) {
super('比赛时间已被他人修改,请刷新后重试');
this.name = 'TimesConflictError';
this.conflicts = conflicts;
}
}
/**
* 批量解析「小组编号」标签(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<Map<string,{teamUid:?number,teamId:?string,teamNumber:?string}>>}
* 键 = 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<Object>} 综合统计数据
*/
async getStats(options = {}) {
const { ctx } = this;
const groups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
// excludeTest=true 时排除测试数据(test_flag=1),仅统计生产作用域
const excludeTest = options && options.excludeTest === true;
const testWhere = excludeTest ? { test_flag: 0 } : {};
// 直接从 contestants 表统计
const totalAccounts = await ctx.model.Contestant.count({ where: testWhere });
const activatedCount = await ctx.model.Contestant.count({
where: { ...testWhere, activated: true },
});
const groupStats = {};
for (const group of groups) {
const baseWhere = { ...testWhere, group };
const totalCount = await ctx.model.Contestant.count({ where: baseWhere });
const activatedGroupCount = await ctx.model.Contestant.count({
where: { ...baseWhere, activated: true },
});
// 提交数:中职/高职查 exam_submissions,本科/教师查 work_submissions
let submittedCount = 0;
let totalScore = 0;
const contestants = await ctx.model.Contestant.findAll({
where: baseWhere,
attributes: ['id'],
});
const contestantIds = contestants.map(c => c.id);
if (contestantIds.length > 0) {
const 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<Object>} { list, total, page, pageSize }
*/
async getAccounts(options = {}) {
const { ctx } = this;
const { group, keyword, activated, startDate, endDate } = options;
const page = parseInt(options.page, 10) || 1;
const pageSize = parseInt(options.pageSize, 10) || 20;
const offset = (page - 1) * pageSize;
// 构建查询条件
const where = {};
if (group) {
where.group = group;
}
// 激活状态筛选(2026-10-08 修复:此前 controller 未透传、本函数也未解构该参数,
// ⇒ 选手管理页「激活状态」下拉完全失效且不报错)。
// ⚠️ 前端 el-select 传的是字符串 'true'/'false',**不能直接塞进 where**:
// activated 是 BOOLEAN(TINYINT(1)) 列,MySQL 会把 'true' 隐式转成 0,
// 结果「已激活」错查出「未激活」。必须显式归一化为布尔。
// 白名单之外的值(含 undefined/'' 等)一律视为「未指定」忽略,
// 避免畸形入参被当成 false 而静默缩窄结果集。
if ([true, 1, '1', 'true'].includes(activated)) {
where.activated = true;
} else if ([false, 0, '0', 'false'].includes(activated)) {
where.activated = false;
}
if (keyword) {
where[ctx.model.Sequelize.Op.or] = [
{ name: { [ctx.model.Sequelize.Op.like]: `%${keyword}%` } },
{ id_number: { [ctx.model.Sequelize.Op.like]: `%${keyword}%` } },
{ phone: { [ctx.model.Sequelize.Op.like]: `%${keyword}%` } },
];
}
if (startDate && endDate) {
where.created_at = {
[ctx.model.Sequelize.Op.gte]: new Date(startDate + 'T00:00:00'),
[ctx.model.Sequelize.Op.lte]: new Date(endDate + 'T23:59:59'),
};
} else if (startDate) {
where.created_at = { [ctx.model.Sequelize.Op.gte]: new Date(startDate + 'T00:00:00') };
} else if (endDate) {
where.created_at = { [ctx.model.Sequelize.Op.lte]: new Date(endDate + 'T23:59:59') };
}
const { rows, count } = await ctx.model.Contestant.findAndCountAll({
where,
attributes: { exclude: ['password'] },
order: [['id', 'DESC']],
offset,
limit: pageSize,
});
// ---- R90:补齐「小组编号 / 所属学校」(与 getTeams 同源口径,供选手列表对照)----
// 口径(勿另立第二套):
// · 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)
// · teamUid 解析优先级:aggregation.team_uid(报名表聚合,权威) → contestants.team_id(兜底)
// · schoolName 取自 aggregation.organization_cn
// 批量查询(2 条 SQL)而非逐行查,避免 N+1。
const Op = ctx.model.Sequelize.Op;
const pageIds = rows.map(r => r.id);
const aggTeamUid = new Map(); // contestant.id → teamUid
const aggSchool = new Map(); // contestant.id → 学校名
if (pageIds.length > 0) {
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: { [Op.in]: pageIds } },
attributes: ['account_uid', 'team_uid', 'organization_cn'],
raw: true,
});
for (const a of aggRows) {
if (a.team_uid !== null && a.team_uid !== undefined && !aggTeamUid.has(a.account_uid)) {
aggTeamUid.set(a.account_uid, a.team_uid);
}
const sch = typeof a.organization_cn === 'string' ? a.organization_cn.trim() : '';
if (sch && !aggSchool.has(a.account_uid)) aggSchool.set(a.account_uid, sch);
}
}
// 队伍元信息(人类可读队号 team_id / team_number),按 (group, teamUid) 建索引
const resolveTeamUid = row => {
if (aggTeamUid.has(row.id)) return aggTeamUid.get(row.id);
return row.team_id === null || row.team_id === undefined ? null : row.team_id;
};
const teamUids = Array.from(new Set(rows.map(resolveTeamUid).filter(u => u !== null)));
const teamMeta = new Map();
if (teamUids.length > 0) {
const teamRows = await ctx.model.Team.findAll({
where: { team_uid: { [Op.in]: teamUids } },
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
for (const t of teamRows) teamMeta.set(t.group_name + '#' + t.team_uid, t);
}
// 转换为前端期望的字段格式
const list = rows.map(row => {
const teamUid = resolveTeamUid(row);
const meta = teamUid === null ? null : (teamMeta.get(row.group + '#' + teamUid) || null);
return {
uid: row.id,
name: row.name,
name_cn: row.name,
idNumber: row.id_number,
id_number: row.id_number,
phone: row.phone,
group: row.group,
activated: row.activated,
score: row.score,
test_flag: row.test_flag,
createdAt: row.created_at,
created_at: row.created_at,
// R90 新增(既有字段一律不变)
teamUid,
teamId: meta ? meta.team_id : null,
teamNumber: meta ? (meta.team_number || meta.team_id) : null,
schoolName: aggSchool.get(row.id) || '',
};
});
return {
list,
total: count,
page: parseInt(page),
pageSize: parseInt(pageSize),
};
}
/**
* 获取「小组(团队)激活状态」清单(R84 续六新增 · 人工测试反馈需求)
*
* 需求原文:「在选手管理中加入一个查看已激活/未激活小组的功能,且必须携带小组所属学校名称」。
*
* 口径(本函数的**唯一真相源**,改动前请先读这里):
* - **小组的键 = (组别 group, 队伍号 teamUid)**。⛔ 绝不能用 teamUid 单独作键:
* 实测 `team_uid=1` 同时存在于 zhongzhi 与 benke(见 `service/exam.js` checkTeamLock
* 的跨组复用防护注释),单独用 teamUid 会把两个不同组的小组并成一个。
* - **成员来源为双来源并集**(与 checkTeamLock 保持一致):
* ① `aggregation.team_uid`(报名表聚合填充,**同时是学校名称的来源**)
* ② `contestants.team_id`(导入流程填充,兼容旧数据)
* 同一选手若两处都有归属,**只计一次**(以 aggregation 为准,避免重复计数)。
* - **学校名称**取自 `aggregation.organization_cn`。同一小组理论上同校;
* 若出现多校(异常数据),并列展示并置 `schoolConflict=true` 供排查,**不静默取一个**。
* - **三态判定**(字段 `status`):`activated`(全员激活)/ `partial`(部分激活)/ `inactive`(全未激活)。
* - **🔴「已激活」语义(R90 续三修正)**:行字段 `activated` = 组内**至少 1 名**成员已激活
* —— 设计目的即为「确认该小组是否已有人激活、**可以参赛**」,故 `partial` 归入**已激活**侧,
* 仅 `inactive` 归入**未激活**侧(旧口径「全员激活」与产品语义不符,已修正)。
* 严格「全员激活」语义另存于 `allActivated`;前端表格按 `status` 渲染三态标签,显示不受影响。
* - **测试数据口径**:默认 `includeTest=false` ⇒ 只统计 `test_flag=0` 的成员;
* 某小组若成员全为测试数据,则该小组整体不出现(这正是「生产口径」想要的)。
*
* @param {Object} options - { group?, activated?, includeTest? }
* @returns {Promise<Object>} { list, summary, filteredTotal, filters }
* summary 为**筛选前全集**口径,含 total/activated/partial/inactive/memberTotal/memberActivated
* 以及 R90 增补的 memberActivatedInPartial / filteredTotal / filteredMemberTotal / filteredMemberActivated
* 与 R90 续三增补的 filteredActivated / filteredPartial / filteredInactive(筛选后三态分布)
*/
async getTeams(options = {}) {
const { ctx } = this;
const { group, activated } = options;
const includeTest = options.includeTest === true || options.includeTest === 'true' || options.includeTest === '1';
const GROUP_CN = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
// ---- 1) 成员(选手):只取本功能所需列,避免把 password 等带出来 ----
const contestantWhere = {};
if (group) contestantWhere.group = group;
if (!includeTest) contestantWhere.test_flag = 0;
const contestants = await ctx.model.Contestant.findAll({
where: contestantWhere,
attributes: ['id', 'group', 'team_id', 'activated'],
raw: true,
});
const contestantById = new Map();
for (const c of contestants) contestantById.set(c.id, c);
// ---- 2) 聚合报名(团队归属 + 学校名称)----
const aggs = await ctx.model.Aggregation.findAll({
attributes: ['account_uid', 'team_uid', 'organization_cn'],
raw: true,
});
// ---- 3) 团队元信息(人类可读队伍编号 team_id / team_number)----
const teamWhere = group ? { group_name: group } : {};
const teamRows = await ctx.model.Team.findAll({
where: teamWhere,
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
const teamMeta = new Map();
for (const t of teamRows) teamMeta.set(t.group_name + '#' + t.team_uid, t);
// ---- 4) 归集:(组别, 队伍号) → 成员 / 激活数 / 学校 ----
const buckets = new Map();
const keyOf = (g, uid) => g + '#' + uid;
const touch = (g, uid) => {
const k = keyOf(g, uid);
let b = buckets.get(k);
if (!b) {
b = { group: g, teamUid: uid, memberIds: new Set(), activatedCount: 0, schools: new Set() };
buckets.set(k, b);
}
return b;
};
// 4a) 来源一:aggregation.team_uid(先跑,作为「已归属」判定的基准)
const assigned = new Set();
for (const a of aggs) {
if (a.team_uid === null || a.team_uid === undefined) continue;
const c = contestantById.get(a.account_uid);
if (!c) continue; // 不在本次口径内(被组别/测试数据过滤掉)
const b = touch(c.group, a.team_uid);
if (!b.memberIds.has(c.id)) {
b.memberIds.add(c.id);
if (c.activated) b.activatedCount += 1;
}
assigned.add(c.id);
const sch = typeof a.organization_cn === 'string' ? a.organization_cn.trim() : '';
if (sch) b.schools.add(sch);
}
// 4b) 来源二:contestants.team_id(兜底;已由 aggregation 计过的选手跳过 ⇒ 防重复计数)
for (const c of contestants) {
if (c.team_id === null || c.team_id === undefined) continue;
if (assigned.has(c.id)) continue;
const b = touch(c.group, c.team_id);
if (!b.memberIds.has(c.id)) {
b.memberIds.add(c.id);
if (c.activated) b.activatedCount += 1;
}
}
// ---- 5) 组装 + 状态判定 ----
const all = [];
for (const b of buckets.values()) {
const memberCount = b.memberIds.size;
const activatedCount = b.activatedCount;
const meta = teamMeta.get(keyOf(b.group, b.teamUid)) || null;
const status = activatedCount === 0
? 'inactive'
: (activatedCount === memberCount ? 'activated' : 'partial');
all.push({
group: b.group,
groupName: GROUP_CN[b.group] || b.group,
teamUid: b.teamUid,
// teams 表可能没有对应行(旧数据只写了 aggregation)⇒ 回退为空,前端展示「—」
teamId: meta ? meta.team_id : null,
teamNumber: meta ? (meta.team_number || meta.team_id) : null,
schoolName: Array.from(b.schools).join(' / '),
schoolConflict: b.schools.size > 1,
memberCount,
activatedCount,
// 🔴 R90 续三:「已激活」= 组内**至少 1 名**成员已激活(= 可以参赛),partial 归入已激活侧。
// 旧口径「全员激活」与产品语义(确认小组是否已有人激活可参赛)不符,已修正。
// 筛选逻辑随之无需改动(下方 list 过滤仍按本字段判定)。
activated: memberCount > 0 && activatedCount > 0,
// 严格「全员激活」语义(前端表格按 status 渲染三态标签,本项供需严格判定的场景)
allActivated: memberCount > 0 && activatedCount === memberCount,
status,
});
}
// 稳定排序:组别 → 队伍号(数值升序;非数值队号排后面)
const GROUP_ORDER = { zhongzhi: 0, gaozhi: 1, benke: 2, jiaoshi: 3 };
all.sort((x, y) => {
const gx = GROUP_ORDER[x.group] === undefined ? 9 : GROUP_ORDER[x.group];
const gy = GROUP_ORDER[y.group] === undefined ? 9 : GROUP_ORDER[y.group];
if (gx !== gy) return gx - gy;
return (x.teamUid || 0) - (y.teamUid || 0);
});
const summarize = arr => ({
total: arr.length,
activated: arr.filter(t => t.status === 'activated').length,
partial: arr.filter(t => t.status === 'partial').length,
inactive: arr.filter(t => t.status === 'inactive').length,
memberTotal: arr.reduce((s, t) => s + t.memberCount, 0),
memberActivated: arr.reduce((s, t) => s + t.activatedCount, 0),
});
// 筛选(与 getAccounts 的 activated 归一化同理:前端 el-select 传字符串)
// 🔴 R90 续三口径:activated=true ⇒「至少 1 名成员已激活」= activated 组 + partial 组(可参赛);
// activated=false ⇒ 仅 inactive 组(无任何成员激活)。判据统一挂在行字段 activated 上。
let list = all;
if ([true, 1, '1', 'true'].includes(activated)) {
list = all.filter(t => t.activated === true);
} else if ([false, 0, '0', 'false'].includes(activated)) {
list = all.filter(t => t.activated === false);
}
// R90:给前端「双口径」汇总用(人工测试反馈:筛选后小组数 3 与成员激活数 4 看似矛盾)。
// 既有 6 个字段语义一律不变(仍为**筛选前全集**口径),仅新增下列字段:
// · memberActivatedInPartial —— 「部分激活」组内已激活的成员数(恒为全集口径)。
// · filteredTotal / filteredMemberTotal / filteredMemberActivated —— **当前筛选后**口径。
// R90 续三:口径修正为「activated = 至少 1 名成员激活」后,选中「已激活」时 partial 组一并纳入
// ⇒ filteredMemberActivated 恒等于全集 memberActivated(inactive 组贡献 0),
// 「选手列表激活人数」与「已激活小组成员数」不再有差额。
// 另补 filteredActivated / filteredPartial / filteredInactive,供前端展示筛选后三态分布。
const allSummary = summarize(all);
const filteredSummary = summarize(list);
return {
list,
summary: {
...allSummary,
memberActivatedInPartial: all
.filter(t => t.status === 'partial')
.reduce((s, t) => s + t.activatedCount, 0),
filteredTotal: filteredSummary.total,
filteredMemberTotal: filteredSummary.memberTotal,
filteredMemberActivated: filteredSummary.memberActivated,
// R90 续三:筛选后三态分布(选中「已激活」时 = 全员激活 + 部分激活)
filteredActivated: filteredSummary.activated,
filteredPartial: filteredSummary.partial,
filteredInactive: filteredSummary.inactive,
},
// summary 作用于**筛选前**的全集(给总览用);filteredTotal 说明筛选后条数
filteredTotal: list.length,
filters: { group: group || '', activated: activated === undefined ? '' : activated, includeTest },
};
}
// ==================== Excel 导入 ====================
/**
* 从 Excel 文件导入账号数据
* @param {string} filePath - Excel 文件路径
* @returns {Promise<Object>} { imported, skipped, errors }
*/
async importFromExcel(filePath, options = {}) {
const { ctx } = this;
// 组别归一化:兼容中文全称(中职组/高职组/本科组/教师组)与代码(zhongzhi 等),
// 避免用户直接用原始花名册(中文组别)导入时全部被 contestants.group 的 isIn 校验拒绝、
// 静默回滚成 imported=0 却仍返回 HTTP 200「成功」的假成功。
const GROUP_ALIAS = {
'中职': 'zhongzhi', '中职组': 'zhongzhi', '中職': 'zhongzhi', '中職組': 'zhongzhi',
'高职': 'gaozhi', '高职组': 'gaozhi', '高職': 'gaozhi', '高職組': 'gaozhi',
'本科': 'benke', '本科组': 'benke', '本科組': 'benke',
'教师': 'jiaoshi', '教师组': 'jiaoshi', '教師': 'jiaoshi', '教師組': 'jiaoshi',
};
const normalizeGroup = (g) => {
if (!g) return '';
const s = String(g).trim();
return GROUP_ALIAS[s] || s;
};
// 列名别名归一:上游赛事系统导出的「报名信息表」与后端内部列名契约不一致
// (「身份证号码」vs「身份证号」、「全名」vs「姓名」、「队伍ID」vs「队伍编号」),
// 直接导入会因取不到列而把整表判为「身份证号为空」全量跳过(R31 实测 0/1225)。
// 数组顺序即优先级;取首个非空值,使上游导出表无需人工改列名即可导入。
const COLUMN_ALIAS = {
身份证号: ['身份证号', '身份证号码', '身份证', 'id_number'],
姓名: ['姓名', '全名', 'name'],
队伍编号: ['队伍编号', '队伍ID', '队伍id', 'team_id', 'team_number'],
手机号: ['手机号', '手机号码', '联系电话', 'phone'],
组别: ['组别', 'group'],
身份: ['身份', 'identity'],
// R84 续六新增:上游「报名信息表」确实带「学校/单位名称全称」列,但此前列名契约
// **有意忽略**该列 ⇒ 落库时丢失 ⇒ 生产库 `aggregation.organization_cn`
// 600 行全为空(2026-10-08 只读取证)⇒「小组所属学校」无从展示。
// 现补入契约,写入 `aggregation.organization_cn`(该列运行库既有,模型声明见
// `model/aggregation.js`)。列名以上游导出为准,另兼容几种常见写法以防改版。
学校: ['学校/单位名称全称', '学校/单位名称全称(英文)', '学校名称', '单位名称', '学校', 'organization_cn'],
};
// 按别名顺序取首个非空值(统一 trim,返回字符串)
const pick = (row, key) => {
const aliases = COLUMN_ALIAS[key] || [key];
for (const k of aliases) {
const v = row[k];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
}
return '';
};
// 只导入「参赛选手」:报名审核表同一队伍内含报名人 / 指导教师(专家、教练) / 团队支持(领队) 等身份,
// 这些身份不建立参赛账号(不参与考试与作品提交)。归一化去空白后精确匹配,
// 避免因上游表格中的空白字符差异导致漏导。
const IMPORT_IDENTITY = '参赛选手';
const normalizeIdentity = (s) => String(s || '').replace(/\s+/g, '');
const isImportableIdentity = (s) => normalizeIdentity(s) === IMPORT_IDENTITY;
// 是否将本批次标记为测试数据(test_flag=1)。
// 优先使用显式入参;未传时回退到运行模式:测试环境导入默认归类为测试数据,
// 生产环境导入默认归类为生产数据(差异最小化,调用方无需区分环境)。
const importAsTest = (options && typeof options.testFlag === 'boolean')
? options.testFlag
: ctx.app.config.isTest;
// 测试数据统一使用「默认口令」的 bcrypt 哈希,便于重置/导入后直接登录重测。
let defaultTestHash = null;
if (importAsTest) {
// 🔴 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<Object>}
*/
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<Array>} 答题记录
*/
async getExamSubmissions(group) {
const { ctx } = this;
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const contestants = await ctx.model.Contestant.findAll({
where: { group },
// 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<Array>} [{ contestantId, name, idNumber, phone, group, files: [{ id, kind, fileName, filePath, fileSize, score, uploadedAt, scoreField }] }]
*/
async getWorks(group) {
const { ctx } = this;
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const contestants = await ctx.model.Contestant.findAll({
where: { group },
// team_id 供 resolveTeamLabels 在「aggregation.team_uid 缺失」时兜底
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'team_id', 'word_score', 'ppt_score', 'exam_score'],
order: [['id', 'ASC']],
raw: true,
});
// 🔴 R190:小组编号 / 小组目录名(口径唯一来源,⛔ 不得在此另算一套)
const labelMap = await resolveTeamLabels(ctx, contestants);
const teamInfoOf = (contestantId, aggUid) => {
const label = labelMap.get(String(contestantId)) || {};
const teamUid = label.teamUid === undefined ? null : label.teamUid;
const teamNumber = label.teamNumber || null;
return {
teamUid,
teamNumber,
// 后端落盘用的目录名;前端只展示,⛔ 不参与拼接
teamFolder: resolveTeamFolderName({ teamNumber, teamUid, aggregationUid: aggUid }),
};
};
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 aggUid = contestantToAgg[c.id];
const subs = byAgg[aggUid] || [];
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,
aggregationUid: aggUid === undefined ? null : aggUid,
...teamInfoOf(c.id, aggUid),
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,
...teamInfoOf(c.id, null),
files,
};
});
}
// ==================== 成绩导出(CSV / Excel,R29) ====================
/**
* 成绩导出**单一数据源**:表头 + 数据行。
*
* CSV 与 Excel 两个出口都必须走这里 —— 若各写一套,迟早出现
* 「Excel 加了新列、CSV 没加」这类表头漂移(本项目已被同类问题咬过多次)。
*
* @param {string} group - 组别
* @returns {Promise<{headers: string[], rows: Array<Array<string|number>>}>}
*/
async buildScoreExportTable(group) {
const isExam = group === 'zhongzhi' || group === 'gaozhi';
if (isExam) {
// 中职/高职组:客观题(系统自动评分) + 综合大题(人工阅卷)
// 说明:大题分落库列名为 contestants.word_score(历史沿用,语义已变为「综合大题得分」),
// 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
const submissions = await this.getExamSubmissions(group);
return {
// 🔴 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<string>} CSV 内容
*/
async exportToCsv(group) {
const { headers, rows } = await this.buildScoreExportTable(group);
// 字符串一律加引号(原文对身份证 / 姓名 / 手机号 / 文件名即如此),数值保持裸值
const cell = v => {
if (v == null) return '';
return typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : String(v);
};
return [headers, ...rows].map(r => r.map(cell).join(',')).join('\n') + '\n';
}
/**
* 导出某组成绩为真正的 Excel(.xlsx)(R29 需求)。
*
* 测试人员反馈「导出 CSV 不如直接导出 Excel」:CSV 在 Excel 中打开时,
* 中文易乱码、18 位身份证号 / 11 位手机号会被转成科学计数法,需手工「分列」。
* 直接用已装依赖 xlsx(SheetJS) 生成 xlsx,打开即为规整表格。
*
* @param {string} group - 组别
* @returns {Promise<Buffer>} xlsx 二进制
*/
async exportToXlsx(group) {
const { headers, rows } = await this.buildScoreExportTable(group);
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
// 列宽:文件名较长、身份证号 18 位,给足宽度让导出即可读
ws['!cols'] = headers.map(h => {
if (/文件名/.test(h)) return { wch: 32 };
if (/身份证号/.test(h)) return { wch: 20 };
return { wch: 12 };
});
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '成绩单');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
/**
* 成绩公示表导出(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 {Array<{absPath:string, zipName:string}>} entries - 条目清单
* @returns {Promise<string>} zip 绝对路径
* @private
*/
async _buildZip(entries) {
const { app } = this;
// 使用 archiver 打包。archiver v8 为 ESM,导出 ZipArchive 等类;用 new ZipArchive(...) 实例化
const { ZipArchive } = require('archiver');
const zipFilePath = path.join(app.baseDir, 'app/public/tmp/zip', `zip_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.zip`);
fs.mkdirSync(path.dirname(zipFilePath), { recursive: true });
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);
// 🔴 直接 file() 到目标条目名,⛔ 不再先 copyFileSync 到临时目录再按目录遍历:
// 复制步骤既多一次全量磁盘写,也会让「重名文件」在临时目录里互相覆盖。
for (const e of entries) {
archive.file(e.absPath, { name: e.zipName });
}
archive.finalize();
});
return zipFilePath;
}
/**
* 批量下载某组所有提交文件,打包为 ZIP
*
* 🔴 R190 改造:本科/教师组**按小组分文件夹**打包 ——
* 旧实现把所有文件平铺在 zip 根下,命名为 `姓名-word-原名`,
* 两个文件(Word/PPT)混在一堆同类文件里,评分时无法判断哪两个属于同一小组。
* 新结构:`<小组编号>/<原始文件名>`,与管理端「单个小组压缩包」完全同构。
* 中职/高职组(Word 答题答案)保持原有平铺命名,不受影响。
*
* @param {string} group - 组别
* @returns {Promise<string>} ZIP 文件路径
*/
async batchDownloadZip(group) {
const { app } = this;
// 获取该组的所有提交记录(考试组与作品组数据形状不同)
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const submissions = isExam
? await this.getExamSubmissions(group)
: await this.getSubmissions(group);
// works 列表提供「选手 → 小组目录名」映射(口径与上传落盘同源)
const worksList = isExam ? [] : await this.getWorks(group);
const folderByContestant = new Map();
for (const w of worksList) {
folderByContestant.set(String(w.contestantId), w.teamFolder);
}
const entries = [];
const usedNames = new Set();
/** 同名条目去重(历史数据里同组两人都传过时会撞名) */
const uniqueZipName = (desired) => {
if (!usedNames.has(desired)) { usedNames.add(desired); return desired; }
const slash = desired.lastIndexOf('/');
const dirPart = slash >= 0 ? desired.slice(0, slash + 1) : '';
const base = slash >= 0 ? desired.slice(slash + 1) : desired;
const dot = base.lastIndexOf('.');
const stem = dot > 0 ? base.slice(0, dot) : base;
const ext = dot > 0 ? base.slice(dot) : '';
for (let i = 2; i <= 99; i += 1) {
const cand = `${dirPart}${stem}-${i}${ext}`;
if (!usedNames.has(cand)) { usedNames.add(cand); return cand; }
}
const cand = `${dirPart}${stem}-${Date.now()}${ext}`;
usedNames.add(cand);
return cand;
};
// 本科/教师组需要 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);
entries.push({ absPath: fullPath, zipName: uniqueZipName(`${userName}-${origName}`) });
}
}
} else {
// 本科/教师组:按小组目录归集 Word + PPT
const folder = folderByContestant.get(String(item.contestantId)) || '未编组';
for (const sub of (item.workSubmissions || [])) {
if (!sub.file_path) continue;
const fullPath = path.join(app.baseDir, 'app/public', sub.file_path);
if (!fs.existsSync(fullPath)) continue;
const origName = (sub.file_name || path.basename(fullPath)).replace(/[\\/:*?"<>|]/g, '_');
entries.push({ absPath: fullPath, zipName: uniqueZipName(`${folder}/${origName}`) });
}
}
}
if (entries.length === 0) {
const err = new Error('该组暂无已上传的作品文件');
err.bizCode = 'EMPTY';
throw err;
}
return await this._buildZip(entries);
}
/**
* 单小组作品压缩包(R190)
*
* 【业务来源】「管理人员下载作品时将这个文件夹进行压缩,直接下载压缩包」
* ⇒ 单个小组 = 一个压缩包,包内**平铺**该小组的两个文件(不再套一层目录)。
*
* @param {string} group - benke / jiaoshi
* @param {Object} p
* @param {?number} p.teamUid - 队伍号(优先)
* @param {?number} p.aggregationUid - 聚合报名 ID(无队伍号时的定位键)
* @returns {Promise<{zipFilePath:string, downloadName:string, folderName:string, fileCount:number}>}
*/
async teamZip(group, { teamUid = null, aggregationUid = null } = {}) {
const { app } = this;
const works = await this.getWorks(group);
// 定位本小组的成员行:有 teamUid 用 teamUid(组内全部成员),否则用 aggregationUid
let target;
if (teamUid !== null && teamUid !== undefined && String(teamUid) !== '') {
const members = works.filter(w => String(w.teamUid) === String(teamUid));
if (members.length === 0) {
const err = new Error('未找到该小组');
err.bizCode = 'NOT_FOUND';
throw err;
}
target = members;
} else {
const one = works.find(w => String(w.aggregationUid) === String(aggregationUid));
if (!one) {
const err = new Error('未找到该小组的报名信息');
err.bizCode = 'NOT_FOUND';
throw err;
}
target = [one];
}
const folderName = target[0].teamFolder || '未编组';
const entries = [];
const usedNames = new Set();
for (const w of target) {
for (const f of (w.files || [])) {
if (!f.filePath) continue;
const abs = path.join(app.baseDir, 'app/public', f.filePath);
if (!fs.existsSync(abs)) continue;
let name = (f.fileName || path.basename(abs)).replace(/[\\/:*?"<>|]/g, '_');
if (usedNames.has(name)) {
const dot = name.lastIndexOf('.');
const stem = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let i = 2;
while (usedNames.has(`${stem}-${i}${ext}`)) i += 1;
name = `${stem}-${i}${ext}`;
}
usedNames.add(name);
entries.push({ absPath: abs, zipName: name });
}
}
if (entries.length === 0) {
const err = new Error('该小组暂无已上传的作品文件');
err.bizCode = 'EMPTY';
throw err;
}
const zipFilePath = await this._buildZip(entries);
return {
zipFilePath,
downloadName: `${folderName}_作品.zip`,
folderName,
fileCount: entries.length,
};
}
// ==================== 评分管理 ====================
/**
* 批量更新评分
* @param {Array} scores - 评分数组 [{ submissionId, score }]
* @param {string} group - 组别
*/
async updateScores(scores, group) {
const { ctx } = this;
for (const item of scores) {
const { contestantId, examScore, wordScore, pptScore } = item;
if (!contestantId) continue;
const contestant = await ctx.model.Contestant.findByPk(contestantId);
if (!contestant) continue;
// P2-2 事务补全:单个选手的评分涉及 contestants + accounts + 作品提交表
// 多表写入,包裹在同一事务中,任一步失败整体回滚,避免"改了总分、
// 没同步分项"的中间状态脏数据。
await ctx.model.transaction(async (t) => {
if (group === 'zhongzhi' || group === 'gaozhi') {
// 中职/高职组:客观题(系统评分)+ Word(人工评分)
let examS;
if (examScore !== undefined && examScore !== null && examScore !== '') {
examS = Number(examScore);
} else {
// 未显式传客观题分则按逐题分数自动求和
examS = await this._computeObjectiveScore(contestantId, group);
}
const wordS = (wordScore !== undefined && wordScore !== null && wordScore !== '')
? Number(wordScore)
: (contestant.word_score || 0);
await contestant.update({
exam_score: examS,
word_score: wordS,
score: examS + wordS,
}, { transaction: t });
} else {
// 本科/教师组:Word(人工评分)+ PPT(人工评分)
const wordS = (wordScore !== undefined && wordScore !== null && wordScore !== '')
? Number(wordScore)
: (contestant.word_score || 0);
const pptS = (pptScore !== undefined && pptScore !== null && pptScore !== '')
? Number(pptScore)
: (contestant.ppt_score || 0);
await contestant.update({
word_score: wordS,
ppt_score: pptS,
score: wordS + pptS,
}, { transaction: t });
// 历史兼容:同步 accounts.score
const aggregation = await ctx.model.Aggregation.findOne({
where: { account_uid: contestantId },
transaction: t,
});
if (aggregation && aggregation.account_uid) {
await ctx.model.Account.update(
{ score: wordS + pptS },
{ where: { uid: aggregation.account_uid }, transaction: t }
);
}
}
// 将分项分数同步到作品提交记录(按文件查看分项)
await this._syncSubmissionScores(contestantId, group, {
wordS: contestant.word_score || 0,
pptS: contestant.ppt_score || 0,
}, t);
});
}
}
/**
* 计算选手客观题自动评分总和(中职/高职组)
* @param {number} contestantId - 选手 ID
* @param {string} group - 组别
* @returns {Promise<number>}
*/
async _computeObjectiveScore(contestantId, group) {
const { ctx } = this;
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const rows = await SubmissionModel.findAll({
where: { aggregation_uid: contestantId },
attributes: ['score'],
});
return rows.reduce((sum, r) => sum + (r.score || 0), 0);
}
/**
* 将分项分数同步到作品提交记录(Word / PPT 各自一行)
* @param {number} contestantId - 选手 ID
* @param {string} group - 组别
* @param {Object} scores - { wordS, pptS }
* @param {Object} [transaction] - Sequelize 事务(P2-2:随 updateScores 事务一起提交/回滚)
*/
async _syncSubmissionScores(contestantId, group, { wordS, pptS }, transaction = null) {
const { ctx } = this;
// ⚠️ 仅本科/教师组有作品提交表(benke/jiaoshi_submissions)需要同步分项。
// 中职/高职组分数存于 contestants + 逐题表,若误走 JiaoshiSubmission 会把
// 教师组同 aggregation_uid 的作品分数改错(跨组数据污染,评分不同步 Bug 之一)。
if (group !== 'benke' && group !== 'jiaoshi') return;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// benke_submissions.aggregation_uid 是聚合报名ID,需用 account_uid 解析
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: contestantId },
transaction,
});
const aggUid = agg ? agg.aggregation_uid : contestantId;
const wordSub = await SubmissionModel.findOne({
where: { aggregation_uid: aggUid, file_type: 'word' },
transaction,
});
if (wordSub) await wordSub.update({ score: wordS }, { transaction });
const pptSub = await SubmissionModel.findOne({
where: { aggregation_uid: aggUid, file_type: 'ppt' },
transaction,
});
if (pptSub) await pptSub.update({ score: pptS }, { transaction });
}
/**
* 更新账号的总分(汇总所有提交记录的得分)
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
*/
async _updateAccountTotalScore(aggregationUid, group) {
const { ctx } = this;
let totalScore = 0;
if (group === 'zhongzhi' || group === 'gaozhi') {
// 中职/高职组:aggregation_uid 即考生 ID,直接从 contestants 表更新
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const submissions = await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
attributes: ['score'],
});
totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
await ctx.model.Contestant.update(
{ score: totalScore },
{ where: { id: aggregationUid } }
);
} else {
// 本/教组:aggregation_uid 对应 aggregation 表主键
const aggregation = await ctx.model.Aggregation.findByPk(aggregationUid);
if (!aggregation) return;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const submissions = await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
attributes: ['score'],
});
totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
await ctx.model.Account.update(
{ score: totalScore },
{ where: { uid: aggregation.account_uid } }
);
}
}
// ==================== 题目管理 ====================
/**
* 根据题型获取对应的模型
* @param {string} questionType - 题型:choice / judge / word
* @returns {Object} Sequelize 模型
*/
_getQuestionModel(questionType) {
const { ctx } = this;
switch (questionType) {
case 'choice': return ctx.model.ChoiceQuestion;
case 'judge': return ctx.model.JudgeQuestion;
case 'word': return ctx.model.WordQuestion;
default: throw new Error(`未知题型: ${questionType}`);
}
}
/**
* 计算题目的落库分值。
*
* 客观题(choice / judge)一律强制为 `OBJECTIVE_SCORE_PER_QUESTION`(=1),
* 忽略调用方传入的任何分值;综合性任务(word)保留传入分值(须为正数)。
*
* @param {string} questionType - choice / judge / word
* @param {*} rawScore - 调用方传入的分值(客观题会忽略)
* @returns {number} 实际落库分值
*/
_resolveQuestionScore(questionType, rawScore) {
if (OBJECTIVE_TYPES.includes(questionType)) {
return OBJECTIVE_SCORE_PER_QUESTION;
}
const n = Number(rawScore);
if (!isFinite(n) || n <= 0) {
throw new Error('综合性任务分值(score)必须为大于 0 的合法数值');
}
return n;
}
/**
* 管理考试题目(替换操作:先清空再创建)
* @param {Array} questions - 题目数组
* @param {string} group - 组别
* @param {string} questionType - 题型:choice / judge / word
*/
async manageQuestions(questions, group, questionType) {
const { ctx } = this;
const QuestionModel = this._getQuestionModel(questionType);
const transaction = await ctx.model.transaction();
try {
// 清空该组该题型的所有题目
await QuestionModel.destroy({
where: { group },
transaction,
truncate: false,
});
// 批量插入新题目
const questionData = questions.map((q, index) => {
// 客观题(choice/judge)分值由服务层强制为 1(赛制口径),忽略传入值
const scoreNum = this._resolveQuestionScore(questionType, q.score);
const base = {
group,
score: scoreNum,
sort_order: q.sort_order !== undefined ? q.sort_order : index + 1,
};
if (questionType === 'choice') {
return {
...base,
question: q.question || '',
options: q.options || [],
answer: q.answer || '',
};
} else if (questionType === 'judge') {
return {
...base,
question: q.question || '',
answer: q.answer || 'false',
};
} else {
// word
return {
...base,
title: q.title || q.question || '',
description: q.description || '',
template_path: q.template_path || null,
};
}
});
if (questionData.length > 0) {
await QuestionModel.bulkCreate(questionData, { transaction });
}
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
}
/**
* 添加单道题目
* @param {Object} questionData - 题目数据
* @param {string} group - 组别
* @param {string} questionType - 题型:choice / judge / word
* @returns {Promise<Object>} 创建的题目
*/
async addQuestion(questionData, group, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
// 客观题(choice/judge)一律 1 分(赛制口径);word 题型保留传入分值
const scoreNum = this._resolveQuestionScore(questionType, questionData.score);
const data = {
group,
score: scoreNum,
sort_order: questionData.sort_order || 0,
};
if (questionType === 'choice') {
data.question = questionData.question || '';
data.options = questionData.options || [];
data.answer = questionData.answer || '';
} else if (questionType === 'judge') {
data.question = questionData.question || '';
data.answer = questionData.answer || 'false';
} else {
data.title = questionData.title || questionData.question || '';
data.description = questionData.description || '';
data.template_path = questionData.template_path || null;
}
return await QuestionModel.create(data);
}
/**
* 更新单道题目
* @param {number} questionId - 题目 ID
* @param {Object} questionData - 更新数据
* @param {string} questionType - 题型:choice / judge / word
*/
async updateQuestion(questionId, questionData, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
const question = await QuestionModel.findByPk(questionId);
if (!question) {
throw new Error('题目不存在');
}
const updateData = {
// 客观题分值恒为 1(忽略传入值,防止管理端改回 5 分);word 题型沿用原逻辑
score: OBJECTIVE_TYPES.includes(questionType)
? OBJECTIVE_SCORE_PER_QUESTION
: (questionData.score !== undefined ? Number(questionData.score) : question.score),
sort_order: questionData.sort_order !== undefined ? questionData.sort_order : question.sort_order,
};
if (questionType === 'choice') {
updateData.question = questionData.question !== undefined ? questionData.question : question.question;
updateData.options = questionData.options !== undefined ? questionData.options : question.options;
updateData.answer = questionData.answer !== undefined ? questionData.answer : question.answer;
} else if (questionType === 'judge') {
updateData.question = questionData.question !== undefined ? questionData.question : question.question;
updateData.answer = questionData.answer !== undefined ? questionData.answer : question.answer;
} else {
updateData.title = questionData.title !== undefined ? questionData.title : question.title;
updateData.description = questionData.description !== undefined ? questionData.description : question.description;
updateData.template_path = questionData.template_path !== undefined ? questionData.template_path : question.template_path;
}
await question.update(updateData);
return question;
}
/**
* 删除单道题目
* @param {number} questionId - 题目 ID
* @param {string} questionType - 题型:choice / judge / word
*/
async deleteQuestion(questionId, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
await QuestionModel.destroy({ where: { id: questionId } });
}
/**
* 批量删除题目(按题型)
* @param {number[]} ids - 题目 ID 数组
* @param {string} questionType - choice / judge / word
* @returns {Promise<number>} 删除行数
*/
async bulkDeleteQuestions(ids, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
return await QuestionModel.destroy({ where: { id: ids } });
}
// ==================== 时间管理 ====================
/**
* 批量设置比赛时间
* @param {Array} times - 时间配置数组 [{ group, startTime, endTime, deadline }]
*/
async setTimes(times, options = {}) {
const { ctx } = this;
// ---- B② 乐观锁:先全量校验,全部通过后再写 ----
// ⛔ 顺序不可反:若边校验边 upsert,冲突组之后的组可能已经落库,
// 留下「部分生效」的半成品状态,比直接失败更难收拾。
const expected = options && options.expected;
if (expected && typeof expected === 'object') {
const rows = await ctx.model.CompetitionTime.findAll({
attributes: ['group', 'start_time', 'end_time', 'deadline'],
raw: true,
});
const curMap = new Map(rows.map(r => [r.group, r]));
// 归一到「秒级毫秒数」:与前端 'YYYY-MM-DDTHH:mm:ss' 的精度一致,
// 避免毫秒尾差造成恒定假冲突。
const norm = (v) => {
if (v === null || v === undefined || v === '') return null;
const t = new Date(v).getTime();
return Number.isNaN(t) ? null : Math.floor(t / 1000);
};
const conflicts = [];
for (const t of times) {
const exp = expected[t.group];
if (!exp) continue;
const cur = curMap.get(t.group) || {};
const fields = [['start', 'start_time'], ['end', 'end_time'], ['deadline', 'deadline']];
for (const pair of fields) {
const k = pair[0];
const col = pair[1];
if (!Object.prototype.hasOwnProperty.call(exp, k)) continue;
const want = norm(exp[k]);
const now = norm(cur[col]);
if (want !== now) {
conflicts.push({
group: t.group,
field: k,
expected: exp[k] == null ? null : exp[k],
current: cur[col] == null ? null : cur[col],
});
}
}
}
if (conflicts.length > 0) throw new TimesConflictError(conflicts);
}
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<Array>} 时间配置列表
*/
async getTimes() {
const { ctx } = this;
const times = await ctx.model.CompetitionTime.findAll({
attributes: ['group', 'start_time', 'end_time', 'deadline', 'word_template', 'choice_count', 'judge_count'],
order: [['group', 'ASC']],
raw: true,
});
// 附带各组题库实际题目数,便于后台在「配置抽取数量」时直观看到「题库实际 / 配置抽取」
const fmtLocal = (d) => {
if (!d) return null;
const dt = new Date(d);
const p = (n) => String(n).padStart(2, '0');
return `${dt.getFullYear()}-${p(dt.getMonth() + 1)}-${p(dt.getDate())}T${p(dt.getHours())}:${p(dt.getMinutes())}:${p(dt.getSeconds())}`;
};
const result = [];
for (const t of times) {
const [availableChoice, availableJudge] = await Promise.all([
ctx.model.ChoiceQuestion.count({ where: { group: t.group } }),
ctx.model.JudgeQuestion.count({ where: { group: t.group } }),
]);
result.push({
group: t.group,
startTime: fmtLocal(t.start_time),
endTime: fmtLocal(t.end_time),
deadline: fmtLocal(t.deadline),
wordTemplate: t.word_template || null,
choiceCount: t.choice_count != null ? t.choice_count : 50,
judgeCount: t.judge_count != null ? t.judge_count : 20,
availableChoice,
availableJudge,
});
}
return result;
}
// ==================== 评分查询 ====================
/**
* 获取某组评分列表
* @param {string} group - 组别
* @returns {Promise<Array>} 评分列表
*/
async getScores(group) {
const { ctx } = this;
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'group', 'score', 'exam_score', 'word_score', 'ppt_score', '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<Object>} { choice: [...], judge: [...], word: [...] }
*/
async getQuestions(group) {
const { ctx } = this;
const queryOptions = { where: { group }, order: [['sort_order', 'ASC']], raw: true };
const [choiceQuestions, judgeQuestions, wordQuestions] = await Promise.all([
ctx.model.ChoiceQuestion.findAll(queryOptions),
ctx.model.JudgeQuestion.findAll(queryOptions),
ctx.model.WordQuestion.findAll(queryOptions),
]);
return {
choice: choiceQuestions.map(q => ({
id: q.id,
question_type: 'choice',
question: q.question,
options: typeof q.options === 'string' ? JSON.parse(q.options) : q.options,
answer: q.answer,
score: q.score,
sort_order: q.sort_order,
})),
judge: judgeQuestions.map(q => ({
id: q.id,
question_type: 'judge',
question: q.question,
answer: q.answer,
score: q.score,
sort_order: q.sort_order,
})),
word: wordQuestions.map(q => ({
id: q.id,
question_type: 'word',
title: q.title,
description: q.description,
score: q.score,
sort_order: q.sort_order,
template_path: q.template_path,
})),
};
}
/**
* 批量按题型设置分值:将该组该题型全部题目统一设为指定分值
* @param {string} group - 组别
* @param {string} questionType - choice / judge / word
* @param {number} score - 目标分值
* @returns {Promise<number>} 受影响行数
*/
async setTypeScore(group, questionType, score) {
const { ctx } = this;
const QuestionModel = this._getQuestionModel(questionType);
// 客观题只能被设为 1 分(赛制口径);word 题型按传入值设置
const resolved = this._resolveQuestionScore(questionType, score);
const [updated] = await QuestionModel.update(
{ score: resolved },
{ where: { group } }
);
return updated;
}
/**
* 客观题 Excel 批量导入(单工作表:题型 / 题目 / 选项A~D / 正确答案 / 分值)
* @param {string} filePath - 临时 Excel 文件路径
* @param {Object} options - { group }
* @returns {Promise<{imported:number, skipped:number, errors:Array}>}
*/
async importQuestionsFromExcel(filePath, options = {}) {
const { ctx } = this;
const group = options.group;
const workbook = XLSX.readFile(filePath);
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawRows = XLSX.utils.sheet_to_json(sheet);
// 表头归一化(Excel 导入 0 成功 Bug 修复):
// 测试人员的 Excel 表头常带前后空格 / 全角空格(如「题型 」「 题目」),
// sheet_to_json 的键名严格匹配会导致 row['题型'] 全部 undefined → 整表被
// 「题型无效」跳过。此处将每行键名 trim(含全角空格)后重建,同时兼容常见别名。
const keyAlias = {
'题目类型': '题型', '类型': '题型',
'题干': '题目', '题目内容': '题目',
'答案': '正确答案', '标准答案': '正确答案',
'分数': '分值', '题目分值': '分值',
'A': '选项A', 'B': '选项B', 'C': '选项C', 'D': '选项D',
};
const rows = rawRows.map(raw => {
const clean = {};
for (const k of Object.keys(raw)) {
let nk = String(k).replace(/[\s\u3000\uFEFF]+/g, '');
if (keyAlias[nk]) nk = keyAlias[nk];
clean[nk] = raw[k];
}
return clean;
});
const result = { imported: 0, skipped: 0, errors: [] };
// 先按题型分桶,最后用 bulkCreate 一次性入库,减少逐条事务开销
const buckets = { choice: [], judge: [] };
const optionKeys = ['选项A', '选项B', '选项C', '选项D'];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowIndex = i + 2; // Excel 行号(含表头)
const rawType = String(row['题型'] || '').trim();
let qType = null;
if (['选择题', 'choice'].includes(rawType)) qType = 'choice';
else if (['判断题', 'judge'].includes(rawType)) qType = 'judge';
if (!qType) {
result.errors.push({ row: rowIndex, message: `题型无效:${rawType || '(空)'}` });
result.skipped++;
continue;
}
const question = String(row['题目'] || '').trim();
if (!question) {
result.errors.push({ row: rowIndex, message: '题目内容为空' });
result.skipped++;
continue;
}
let answer = String(row['正确答案'] || '').trim();
let options = null;
if (qType === 'choice') {
options = optionKeys
.map((k, idx) => ({ key: String.fromCharCode(65 + idx), text: String(row[k] || '').trim() }))
.filter(o => o.text);
if (options.length < 2) {
result.errors.push({ row: rowIndex, message: '选择题至少需要 2 个非空选项' });
result.skipped++;
continue;
}
if (!answer) {
result.errors.push({ row: rowIndex, message: '选择题正确答案(选项字母)为空' });
result.skipped++;
continue;
}
// 校验答案字母均落在已填选项内(兼容多选如 AB)
const validKeys = options.map(o => o.key);
const ansKeys = answer.toUpperCase().split('');
if (!ansKeys.every(k => validKeys.includes(k))) {
result.errors.push({ row: rowIndex, message: `正确答案 ${answer} 超出已填选项 ${validKeys.join('')}` });
result.skipped++;
continue;
}
} else {
// 判断题答案归一化:对/正确/true/t/√ -> true;错/错误/false/f/× -> false
const a = answer.toLowerCase();
if (['对', '正确', 'true', 't', '√', '是', 'y', 'yes'].includes(a)) answer = 'true';
else if (['错', '错误', 'false', 'f', '×', '否', 'n', 'no'].includes(a)) answer = 'false';
if (answer !== 'true' && answer !== 'false') {
result.errors.push({ row: rowIndex, message: `判断题正确答案无效:${answer || '(空)'}` });
result.skipped++;
continue;
}
}
// 客观题分值恒为 1(赛制口径)。Excel 的「分值」列一律忽略 ——
// 旧逻辑在缺列/非法时缺省 5 分,正是「高分题」污染题库的来源之一。
const score = OBJECTIVE_SCORE_PER_QUESTION;
const data = { group, question, answer, score, sort_order: 0 };
if (qType === 'choice') data.options = options;
buckets[qType].push(data);
}
// 一次性批量入库(各自在独立事务中,避免单条失败拖垮整批)
for (const qType of ['choice', 'judge']) {
const list = buckets[qType];
if (!list.length) continue;
const QuestionModel = this._getQuestionModel(qType);
const created = await QuestionModel.bulkCreate(list);
result.imported += created.length;
}
return result;
}
/**
* 生成客观题 Excel 导入模板(含表头 + 1 行示例)
* @returns {Promise<Buffer>} xlsx 文件 Buffer
*/
async buildQuestionTemplate() {
// 「分值」列已废弃(客观题恒为 1 分),保留列名仅为兼容旧模板与旧脚本
const header = ['题型', '题目', '选项A', '选项B', '选项C', '选项D', '正确答案', '分值'];
const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 1];
const ws = XLSX.utils.aoa_to_sheet([header, example]);
// 判断题示例行(另起一行,便于参考)
XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 1]], { origin: -1 });
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '客观题');
// ⚠️ sheet 顺序是**契约**:导入侧(importQuestionsFromExcel)按 `SheetNames[0]` 解析,
// 因此数据 sheet 必须排第 1,「填写说明」只能排第 2(删掉也不影响导入)。
// R73:测试人员反馈「不知道导入模板长什么样」⇒ 让模板自解释,不必依赖外部文档。
const notes = [
['客观题导入模板 — 填写说明'],
[],
['列名', '是否必填', '说明'],
['题型', '必填', '只能填「选择题」或「判断题」(英文 choice / judge 也能识别)'],
['题目', '必填', '题干内容'],
['选项A', '选填', '选项内容。选择题至少需 2 个非空选项'],
['选项B', '选填', ''],
['选项C', '选填', ''],
['选项D', '选填', ''],
['正确答案', '必填', '选择题填选项字母(如 C,多选如 AC);判断题填「对」或「错」'],
['分值', '已废弃', '客观题分值口径为「每小题固定 1 分」,本列会被忽略,无需填写'],
[],
['【注意事项】'],
['· 表头必须位于第 1 行,请勿删除或改写表头文字。'],
['· 一行一道题;判断题请把选项列(A..D)留空。'],
['· 导入完成后请在题库列表中核对数量是否与预期一致。'],
];
const notesWs = XLSX.utils.aoa_to_sheet(notes);
notesWs['!cols'] = [{ wch: 14 }, { wch: 10 }, { wch: 66 }];
XLSX.utils.book_append_sheet(wb, notesWs, '填写说明');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
// R177:删掉此处一个**死代码版** `listNews()` —— 它无分页、缺 is_draft/附件字段、
// 排序按 sort_order,且在同一个 class 内被下方同名方法覆盖(JS class 后定义胜出)⇒ 永远不执行。
// ⚠️ 新闻列表的唯一生效实现 = 本文件下方的 `async listNews(query)`(分页 + 含 is_draft/附件字段)。
// 保留这行说明是为了防止后人「以为这里还有一份实现」而改到静默无效的位置。
// ==================== 轮播图查询 ====================
/**
* 获取所有轮播图列表
* @returns {Promise<Array>} 轮播图列表
*/
async listCarousel() {
const { ctx } = this;
const carousels = await ctx.model.Carousel.findAll({
order: [['sort_order', 'ASC']],
raw: true,
});
return carousels.map(c => ({
id: c.id,
image_url: c.image_url,
title: c.title || '',
content: c.content || '',
button_text: c.button_text || '',
button_link: c.button_link || '',
sort_order: c.sort_order,
link_url: c.link_url,
enabled: c.enabled,
created_at: c.created_at,
updated_at: c.updated_at,
}));
}
// ==================== Word 模板管理 ====================
/**
* 上传 Word 模板文件并更新配置
* @param {string} group - 组别
* @param {Object} fileInfo - 文件信息 { filePath }
*/
async uploadWordTemplate(group, fileInfo) {
const { ctx } = this;
await ctx.model.CompetitionTime.upsert({
group: group,
word_template: fileInfo.filePath,
});
}
// ==================== 成绩发布管理 ====================
/**
* 成绩发布状态按组独立存储。
*
* 历史实现只有一个全局键 `score_published`,而管理后台「发布成绩」按钮是按组呈现的
* (scoresPublishedMap[group])——发布任意一组会让四个组的考生同时看到成绩,
* 这正是「中职/高职未发布却能看到成绩」的直接成因之一。
* 现改为每组一个键 `score_published_<group>`(仅新增 system_configs 行,无表结构改动)。
*
* 注意:**不回退读取遗留全局键**。遗留库中该键自建库起即为 'true',
* 一旦作为兜底就会把缺陷重新引回(详见 setScorePublishKey 的还原说明)。
*/
static get PUBLISH_GROUPS() {
return [ 'zhongzhi', 'gaozhi', 'benke', 'jiaoshi' ];
}
/**
* 组别 → 配置键名
* @param {string} group - 组别
* @returns {string} 配置键
*/
_publishKey(group) {
return `score_published_${group}`;
}
/**
* 写入单个配置键(不存在则创建)
* @param {string} key - 配置键
* @param {string} value - 配置值
*/
async _setConfig(key, value, transaction) {
const { ctx } = this;
const opts = transaction ? { transaction } : {};
const [ config, created ] = await ctx.model.SystemConfig.findOrCreate({
where: { config_key: key },
defaults: { config_key: key, config_value: value },
...opts,
});
if (!created && config.config_value !== value) {
await config.update({ config_value: value }, opts);
}
}
/**
* 发布/撤销成绩
* @param {boolean} publish - 是否发布
* @param {string} [group] - 组别;缺省表示对全部组生效(兼容旧调用方)
*/
async publishScores(publish, group, transaction) {
const value = publish ? 'true' : 'false';
const groups = group
? [ group ]
: this.constructor.PUBLISH_GROUPS;
for (const g of groups) {
await this._setConfig(this._publishKey(g), value, transaction);
}
// 同步维护遗留全局键,仅用于兼容仍在读取它的外部工具;
// 业务判定一律以分组键为准。整体发布/撤销时才更新它。
if (!group) {
await this._setConfig('score_published', value, transaction);
}
// ---- 节点备份:成绩「发布」后立即触发(撤销不触发)----
// ⚠️ 用户裁定③:撤销发布时备份到的是**已发布过的数据**,对回退诉求无意义 ⇒
// 只在 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<Array<Object>>} 每组的结果摘要(仅供日志/排查)
*/
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 = [
`<p>本公示表按成绩管理数据自动生成,<strong>尚未定稿</strong>,请核对数据并补齐「获奖等级」后再对外发布。</p>`,
'<p>填表说明:一行代表一个参赛小组;「参赛选手」「指导教师」列多人时以顿号分隔;',
'总分数按由高到低排序,「成绩排名」同序排列。</p>',
`<p>本次导出共 ${built.rowCount} 个小组${built.testCount ? `(其中 ${built.testCount} 人属于测试数据,发布前请确认是否需要剔除)` : ''}。</p>`,
].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<boolean>} 严格布尔值
*/
async isScorePublished(group) {
const { ctx } = this;
if (group) {
const config = await ctx.model.SystemConfig.findOne({
where: { config_key: this._publishKey(group) },
});
// 键缺失 = 未发布(安全默认)。必须返回严格布尔,
// 否则 `config && ...` 在无行时得到 null,前端 `=== true` 判定与断言都会失真。
return !!(config && config.config_value === 'true');
}
const statusMap = await this.getScorePublishMap();
return this.constructor.PUBLISH_GROUPS.every(g => statusMap[g] === true);
}
/**
* 获取各组成绩发布状态
* @returns {Promise<Object>} { zhongzhi: bool, gaozhi: bool, benke: bool, jiaoshi: bool }
*/
async getScorePublishMap() {
const { ctx } = this;
const groups = this.constructor.PUBLISH_GROUPS;
const rows = await ctx.model.SystemConfig.findAll({
where: { config_key: groups.map(g => this._publishKey(g)) },
attributes: [ 'config_key', 'config_value' ],
raw: true,
});
const byKey = {};
rows.forEach(r => { byKey[r.config_key] = r.config_value; });
const result = {};
groups.forEach(g => {
result[g] = byKey[this._publishKey(g)] === 'true';
});
return result;
}
// ==================== 新闻管理 ====================
/**
* 创建新闻
* @param {Object} newsData - 新闻数据
* @returns {Promise<Object>}
*/
async createNews(newsData) {
const { ctx } = this;
return await ctx.model.News.create({
title: newsData.title,
content: newsData.news_type === 'wx_link' ? (newsData.content || null) : (newsData.content || ''),
wx_link: newsData.news_type === 'wx_link' ? (newsData.wx_link || null) : null,
news_type: newsData.news_type || 'content',
publish_time: newsData.publish_time || new Date(),
pinned: newsData.pinned || false,
group: newsData.group || newsData.group_name || 'all',
tags: newsData.tags || null,
// 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<Object>}
*/
async listNews(query) {
const { ctx } = this;
const { page = 1, pageSize = 20, group, pinned } = query || {};
const where = {};
if (group && group !== 'all') {
where.group = group;
}
if (pinned !== undefined) {
where.pinned = pinned;
}
const { count, rows } = await ctx.model.News.findAndCountAll({
where,
order: [['pinned', 'DESC'], ['publish_time', 'DESC']],
offset: (page - 1) * pageSize,
limit: parseInt(pageSize),
raw: true,
});
return { list: rows, total: count, page: parseInt(page), pageSize: parseInt(pageSize) };
}
/**
* 获取新闻详情(管理端)
* @param {number} id - 新闻 ID
* @returns {Promise<Object>}
*/
async getNewsDetail(id) {
const { ctx } = this;
const news = await ctx.model.News.findByPk(id);
if (!news) throw new Error('新闻不存在');
return news;
}
/**
* 添加轮播图
* @param {Object} carouselData - 轮播图数据
* @returns {Promise<Object>}
*/
async addCarousel(carouselData) {
const { ctx } = this;
return await ctx.model.Carousel.create({
image_url: carouselData.image_url || null,
title: carouselData.title || null,
content: carouselData.content || null,
button_text: carouselData.button_text || null,
button_link: carouselData.button_link || null,
sort_order: carouselData.sort_order || 0,
link_url: carouselData.link_url || null,
enabled: carouselData.enabled !== false,
});
}
/**
* 更新轮播图
* @param {number} id - 轮播图 ID
* @param {Object} carouselData - 更新数据
*/
async updateCarousel(id, carouselData) {
const { ctx } = this;
const carousel = await ctx.model.Carousel.findByPk(id);
if (!carousel) throw new Error('轮播图不存在');
await carousel.update({
image_url: carouselData.image_url !== undefined ? carouselData.image_url : carousel.image_url,
title: carouselData.title !== undefined ? carouselData.title : carousel.title,
content: carouselData.content !== undefined ? carouselData.content : carousel.content,
button_text: carouselData.button_text !== undefined ? carouselData.button_text : carousel.button_text,
button_link: carouselData.button_link !== undefined ? carouselData.button_link : carousel.button_link,
sort_order: carouselData.sort_order !== undefined ? carouselData.sort_order : carousel.sort_order,
link_url: carouselData.link_url !== undefined ? carouselData.link_url : carousel.link_url,
enabled: carouselData.enabled !== undefined ? carouselData.enabled : carousel.enabled,
});
return carousel;
}
/**
* 删除轮播图(同时删除关联的图片文件)
* @param {number} id - 轮播图 ID
*/
async deleteCarousel(id) {
const { ctx } = this;
const carousel = await ctx.model.Carousel.findByPk(id);
if (!carousel) throw new Error('轮播图不存在');
// 删除关联的图片文件
if (carousel.image_url) {
const path = require('path');
const fs = require('fs');
const app = this.app;
const filename = carousel.image_url.replace(/^\/public\/carousel\//, '');
if (filename) {
const filePath = path.join(app.baseDir, 'app/public/carousel', filename);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (fileErr) {
ctx.logger.warn('删除轮播图图片文件失败:', fileErr.message);
}
}
}
await ctx.model.Carousel.destroy({ where: { id } });
}
// ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
// 两条取数通路(reviewSearch → getContestantScore)**只允许挂在 admin 路由下**。
/**
* 检索考生(复核入口)
*
* 命中口径:姓名 / 身份证号 / 电话 **任一** 模糊匹配(用户明确要求),且**不限组别** ——
* 复核时管理员通常只拿到一个身份证号或姓名,不该被迫先猜组别;结果里带 group 供界面展示。
*
* @param {string} keyword - 检索词(姓名 / 身份证号 / 电话片段)
* @param {string} [group] - 可选:限定组别
* @returns {Promise<{ok:boolean, code?:string, message?:string, list?:Array, truncated?:boolean}>}
*/
async reviewSearch(keyword, group) {
const { ctx } = this;
const kw = String(keyword == null ? '' : keyword).trim();
// 空关键字返回空列表而非全表:避免误操作把整库考生拖给前端。
if (!kw) return { ok: true, list: [], truncated: false };
if (kw.length > 64) {
return { ok: false, code: 'BAD_KEYWORD', message: '检索词过长(上限 64 字符)' };
}
const { Op } = ctx.model.Sequelize;
const like = { [Op.like]: `%${kw}%` };
const where = { [Op.or]: [{ name: like }, { id_number: like }, { phone: like }] };
if (group) where.group = group;
const LIMIT = 50;
const rows = await ctx.model.Contestant.findAll({
where,
attributes: ['id', 'name', 'id_number', 'phone', 'group'],
order: [['group', 'ASC'], ['id', 'ASC']],
limit: LIMIT,
raw: true,
});
return {
ok: true,
list: rows.map(c => ({
contestantId: c.id,
name: c.name || '',
idNumber: c.id_number || '',
phone: c.phone || '',
group: c.group,
// 客观题只存在于中职/高职;本科/教师的「卷面」是作品文件。界面据此切换展示与提示。
objectiveApplicable: c.group === 'zhongzhi' || c.group === 'gaozhi',
})),
// 命中被截断时如实上报,引导管理员补充更多字符(而非静默只展示前 50 条)。
truncated: rows.length >= LIMIT,
};
}
/**
* 成绩复核:取某考生的**全卷**明细。
*
* 输出 = 客观题逐题(题干 / 选项 / 考生答案 / 正确答案 / 对错 / 得分 / 满分)
* + 综合大题(题干快照 + 三小问作答 + 三小问得分)
* + 本科/教师组的作品文件清单。
*
* 设计约束(每条都对应一个已发生过的坑):
* 1. **归集口径必须与 getExamSubmissions 完全一致**(双键:contestant.id 与真实 aggregation_uid)。
* 否则会出现「列表页说已答 70 题、复核页只有 0 题」—— 管理员会先怀疑数据被篡改。
* 2. **组别以考生自身的 group 为准**,并校验入参 group:防跨组串查。
* 3. 题目按 **exam_submissions.question_order(考生固化的抽题顺序)** 排列,复现他当时所见的题序;
* 无固化顺序的历史数据回退为「选择在前、判断在后,各自按题号升序」。
* 4. `question.answer` **必须回传**(复核场景用户明确要求给出正确答案)。
* ⚠️ 因此本方法**只能挂 admin 路由**:一旦被选手端复用即为成绩泄露
* (参见 scripts/verify_score_leak_all_channels.py 所守的三条通道)。
* 也正因如此这里**不做** isScorePublished 门控 —— admin 是可信边界,
* 而内部核对往往发生在发布之前;发布态只在返回值里**如实上报**供界面提示。
* 5. 题库中已删除 / 改组导致取不到题目时标 `questionMissing`,而非静默留空 ——
* 复核要能区分「考生没答」与「题没了」。
*
* @param {number} contestantId - contestants.id
* @param {string} [group] - 调用方声明的组别(须与考生实际组别一致)
* @returns {Promise<Object>} { ok, ... } ok=false 时带 code/message(不抛异常)
*/
async getContestantScore(contestantId, group) {
const { ctx } = this;
const { Op } = ctx.model.Sequelize;
const cid = parseInt(contestantId, 10);
if (!Number.isFinite(cid)) {
return { ok: false, code: 'BAD_ID', message: '考生 ID 非法' };
}
const contestant = await ctx.model.Contestant.findOne({
where: { id: cid },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'],
raw: true,
});
if (!contestant) return { ok: false, code: 'NOT_FOUND', message: '考生不存在' };
if (group && group !== contestant.group) {
return { ok: false, code: 'GROUP_MISMATCH', message: `组别不符:该考生属于「${contestant.group}」组` };
}
const g = contestant.group;
const isExamGroup = g === 'zhongzhi' || g === 'gaozhi';
const base = {
contestantId: cid,
name: contestant.name || '',
idNumber: contestant.id_number || '',
phone: contestant.phone || '',
group: g,
objectiveApplicable: isExamGroup,
scorePublished: await this.isScorePublished(g), // 只上报,不门控(见方法头第 4 条)
};
if (!isExamGroup) {
// 本科/教师:无客观题,「卷面」即提交的作品文件(Word / PPT)。
return {
ok: true,
...base,
objective: [],
summary: { answered: 0, total: 0, correctCount: 0, autoScore: 0 },
bigQuestion: null,
works: await this._reviewWorks(cid, g),
warnings: [],
};
}
// ---- 1) 归集键(单一真相源 lib/aggregationKey,与「考生答题记录」完全同口径) ----
// 兜底键仅在无歧义(不被同组他人当作主键)时启用,
// 避免「真实聚合 uid 撞上同组他人 contestant.id」而把他人作答记录并入本复核页。
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: cid },
attributes: ['aggregation_uid'],
raw: true,
});
const keys = await resolveAggregationKeysForContestant(
ctx,
g,
cid,
agg ? agg.aggregation_uid : null
);
const SubmissionModel = g === 'zhongzhi' ? ctx.model.ZhongzhiSubmission : ctx.model.GaozhiSubmission;
const subs = await SubmissionModel.findAll({
where: { aggregation_uid: { [Op.in]: keys.map(Number) } },
attributes: ['id', 'aggregation_uid', 'question_uid', 'question_type', 'answer_data', 'is_correct', 'score'],
order: [['id', 'ASC']],
raw: true,
});
const warnings = [];
const usedKeys = [ ...new Set(subs.map(s => String(s.aggregation_uid))) ];
if (usedKeys.length > 1) {
warnings.push('该考生的作答记录同时落在两种聚合键下(' + usedKeys.join(' / ') +
'),已按与「考生答题记录」一致的口径合并归集;若题数异常请人工核对。');
}
// ---- 2) 固化题序(复现考生当时所见次序) ----
const examSub = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: cid },
attributes: ['question_order', 'submit_time'],
raw: true,
});
const rank = new Map();
if (examSub && examSub.question_order) {
try {
const parsed = JSON.parse(examSub.question_order);
if (Array.isArray(parsed)) parsed.forEach((u, i) => rank.set(String(u), i));
} catch (e) {
// 脏数据不抛异常:回退默认排序(复核页不应因一条脏记录整体打不开)
ctx.logger.warn('[getContestantScore] question_order 解析失败 cid=%s', cid);
}
}
// ---- 3) 批量取题干(按题型分表;必须带 group 限定,防跨组同号题串台) ----
const idsByType = { choice: [], judge: [] };
for (const s of subs) {
const t = s.question_type;
// 白名单判定,避免 'constructor' 之类字符串命中 Object 原型链
if (!Object.prototype.hasOwnProperty.call(idsByType, t)) continue;
const n = Number(s.question_uid);
if (Number.isFinite(n)) idsByType[t].push(n);
}
const qMap = new Map();
for (const t of [ 'choice', 'judge' ]) {
const ids = idsByType[t];
if (!ids.length) continue;
const Model = t === 'choice' ? ctx.model.ChoiceQuestion : ctx.model.JudgeQuestion;
const rows = await Model.findAll({ where: { id: { [Op.in]: ids }, group: g }, raw: true });
rows.forEach(r => qMap.set(`${t}:${r.id}`, r));
}
// ---- 4) 拼装逐题明细 ----
const norm = v => String(v == null ? '' : v).trim().toLowerCase();
// 【R28 修复】options 在 DB 里是 text 列、存的是 JSON 字符串。
// 若原样透传给前端,`v-for="op in row.options"` 会按**字符**迭代 ⇒ 每个字符渲染成一行
// 空内容(op.key / op.text 皆 undefined),表现即「选择题大量换行导致空白」。
// 写法与 admin.js:1370 既有口径同源:仅在 typeof === 'string' 时 parse,失败降级 null。
const parseOptions = raw => {
if (raw == null) return null;
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return null;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch (e) {
return null;
}
};
const objective = subs.map(s => {
const t = s.question_type;
const n = Number(s.question_uid);
const q = qMap.get(`${t}:${n}`) || null;
const userAnswer = s.answer_data == null ? '' : String(s.answer_data);
const correct = q ? String(q.answer == null ? '' : q.answer) : null;
const orderKey = `${t}_${n}`;
const seq = rank.has(orderKey) ? rank.get(orderKey)
: (rank.has(String(n)) ? rank.get(String(n)) : null);
return {
seq,
questionType: t,
questionUid: n,
question: q ? q.question : null,
options: q ? parseOptions(q.options) : null,
// 原始值:选择 'A'/'AB';判断 'true'/'false'(由前端做「正确/错误」展示映射,后端不丢事实)
correctAnswer: correct,
maxScore: q && q.score != null ? Number(q.score) : null,
answer: userAnswer,
answered: userAnswer.trim() !== '',
isCorrect: s.is_correct == null ? null : !!s.is_correct,
score: s.score == null ? null : Number(s.score),
// 交叉校验:答案一致却判为错(或反之)即判分异常 —— 复核时最先要看的正是这个。
// ⚠️ 未作答必须给 null 而非 false:'' 与标准答案比对天然不等,
// 若返回 false,「未答」在界面上会被误读成「答错」。
answerMatches: (q && userAnswer.trim() !== '') ? (norm(userAnswer) === norm(correct)) : null,
questionMissing: !q,
};
});
objective.sort((a, b) => {
const ra = a.seq == null ? Number.MAX_SAFE_INTEGER : a.seq;
const rb = b.seq == null ? Number.MAX_SAFE_INTEGER : b.seq;
if (ra !== rb) return ra - rb;
if (a.questionType !== b.questionType) return a.questionType === 'choice' ? -1 : 1;
return a.questionUid - b.questionUid;
});
// 小数按 2 位收敛(与 DECIMAL(6,2) 及全站评分口径一致,避免 0.30000000000000004)
const round2 = v => Math.round(v * 100) / 100;
const autoScore = round2(objective.reduce((sum, o) => sum + (o.score || 0), 0));
// ---- 5) 综合大题(题干取抽题快照,保证考生当时所见可举证) ----
let bigQuestion = null;
const bigRow = await ctx.model.BigQuestionAnswer.findOne({
where: { aggregation_uid: cid },
raw: true,
});
if (bigRow) {
const snap = this.service.bigQuestion.parseSnapshot(bigRow.question_snapshot) || {};
const subPrompts = Array.isArray(snap.sub_prompts) ? snap.sub_prompts : [];
bigQuestion = {
drawn: true,
bigQuestionId: bigRow.big_question_id || null,
bigQuestionUid: bigRow.big_question_uid || null,
title: snap.title || '',
description: snap.description || '',
subPrompts,
scorePerSub: snap.score_per_sub != null ? Number(snap.score_per_sub) : null,
maxTotal: snap.total_score != null ? Number(snap.total_score) : null,
// 固定 3 个槽位,与库表 answer_1..3 对齐(未作答为 '')
answers: [ 1, 2, 3 ].map(i => {
const a = bigRow[`answer_${i}`] == null ? '' : String(bigRow[`answer_${i}`]);
const sc = bigRow[`score_${i}`];
return {
index: i,
prompt: subPrompts[i - 1] || '',
answer: a,
answered: a.trim() !== '',
score: sc == null ? null : Number(sc),
};
}),
totalScore: bigRow.total_score == null ? null : Number(bigRow.total_score),
snapshotAt: snap.snapshot_at || null,
submitTime: bigRow.submit_time || null,
};
}
return {
ok: true,
...base,
objective,
summary: {
answered: objective.length,
total: objective.length,
correctCount: objective.filter(o => o.isCorrect === true).length,
autoScore,
wordScore: contestant.word_score == null ? 0 : Number(contestant.word_score),
totalScore: round2(autoScore + (contestant.word_score == null ? 0 : Number(contestant.word_score))),
},
// 固化题序存在与否决定上图顺序的可解释性,一并上报(便于核对与排障)
questionOrderFixed: rank.size > 0,
matchedKeys: usedKeys,
bigQuestion,
works: [],
warnings,
};
}
/**
* 本科/教师组的作品文件清单(复核用,只读)
*
* 不返回 file_path(服务端磁盘路径不该出网),只给下载所需的 submission id;
* 下载走既有端点 GET /api/admin/submission-file/:group/:id。
*
* @param {number} contestantId - contestants.id
* @param {string} group - benke / jiaoshi
* @returns {Promise<Array>}
*/
async _reviewWorks(contestantId, group) {
const { ctx } = this;
const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission;
// benke/jiaoshi_submissions.aggregation_uid 存的是「聚合报名 ID」,与 contestants.id 不是同一序列,
// 需经 aggregation.account_uid 映射(与 getSubmissions 同口径)。
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: contestantId },
attributes: ['aggregation_uid'],
raw: true,
});
const keys = await resolveAggregationKeysForContestant(
ctx,
group,
contestantId,
agg ? agg.aggregation_uid : null
);
const rows = await SubmissionModel.findAll({
where: { aggregation_uid: { [ctx.model.Sequelize.Op.in]: keys.map(Number) } },
attributes: ['id', 'work_name', 'file_name', 'file_size', 'upload_time', 'score'],
order: [['upload_time', 'DESC']],
raw: true,
});
return rows.map(r => ({
id: r.id,
workName: r.work_name || '',
fileName: r.file_name || '',
fileSize: r.file_size == null ? null : Number(r.file_size),
uploadTime: r.upload_time || null,
score: r.score == null ? null : Number(r.score),
}));
}
/**
* 编辑选手信息(R108 新增)
*
* 背景:此前 `contestants` 只有「Excel 导入」一个写入口 —— 身份证号 / 姓名 / 手机号
* 一旦录错就**没有任何退路**(既不能改也不能删),只能造新行或直接改库。
* 本方法把「已入库选手的身份字段」变成可维护的,用于事后纠正录入错误
* (典型场景:报名表里把证号写错位,核实后再修正)。
*
* ── 可改字段(白名单,其余字段一律**拒绝**而非静默忽略)──
* · name 姓名
* · phone 手机号
* · id_number 身份证号(同时是**登录账号**,见 service/auth.js#findByAccount)
*
* ⚠️ 为什么白名单之外要「报错」而不是「忽略」:静默忽略会让管理员以为改组别
* 成功了,实际没改 —— 属于最难排查的一类故障。此处宁可显式打回。
*
* ── 显式**不可改**的字段及理由 ──
* · group 组别 —— 主观题按组别抽题(big_questions / choice_questions)、
* 成绩按组别发布(score_published_<group>)。改组别会让「已作答的
* 答卷」与「新组别的题目、新组别的发布开关」错配。
* 确需换组请走「重新导入」并另行处置其提交记录。
* · 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<Object>}
* · 成功 `{ 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;