feat(admin): R176-R180 公示表与新闻撤回 + 管理端多管理员权限(C 层)+ 交卷冻结

- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底)
  与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项
- R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据
- R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT)
  + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version)
  + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js
- R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源)
- R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段
- R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态)
- R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
This commit is contained in:
2026-10-20 11:06:29 +08:00
parent 5392345078
commit 459f1adf6d
21 changed files with 2886 additions and 76 deletions

View File

@@ -29,6 +29,78 @@ const { inspectIdNumber } = require('../lib/idCard');
const OBJECTIVE_SCORE_PER_QUESTION = 1;
const OBJECTIVE_TYPES = ['choice', 'judge'];
/**
* 批量解析「小组编号」标签(R178 · 成绩管理「小组编号」列)
*
* 需求原文:「成绩管理中,需要有小组编号来方便查证小组是否完成考试。」
* 成绩总览原本只有姓名/身份证号,无法按小组归并核对 ⇒ 补一列人类可读的小组编号。
*
* 🔴 口径必须与「选手管理」一致,⛔ 不得另立第二套(对照 `getAccounts` 的 R90 段):
* · 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)
* · teamUid 解析优先级:`aggregation.team_uid`(报名表聚合,权威)→ `contestants.team_id`(兜底)
* · 展示值取 `teams.team_id`(人类可读队伍编号,如 BRICS2026-ST-027-91001),
* 缺 `team_number` 时回落同一值 —— 与选手管理 `teamNumber` 口径相同
*
* ⚠️ 为什么不能直接展示 `contestants.team_id`:该列是**内部数字**(实测王学卿=8),
* 与选手管理展示的报名队伍编号不是一回事;两页显示不同值会让「按编号核对」失效。
* ⚠️ 为什么不能只查 teams 表:实测中职组张明远 `aggregation.team_uid=1` 但 teams 表
* 没有 (zhongzhi, 1) 行 —— 属「有归属无元信息」的历史数据,此时标签为 null(前端显示「未编组」)。
*
* @param {Object} ctx - Egg 上下文(用 ctx.model 取模型)
* @param {Array<{id:number, group:string, team_id:?number}>} contestants
* @returns {Promise<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
*
@@ -75,15 +147,25 @@ class AdminService extends Service {
});
const contestantIds = contestants.map(c => c.id);
if (contestantIds.length > 0) {
const SubmissionModel = (group === 'zhongzhi' || group === 'gaozhi')
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: ['contestant_id', 'score'],
attributes: isExamGroup
? ['contestant_id', 'score', 'submit_time']
: ['contestant_id', 'score'],
raw: true,
});
const uniqueIds = new Set(submissions.map(s => s.contestant_id));
// 🔴 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);
}
@@ -545,11 +627,16 @@ class AdminService extends Service {
// 取一个可用的竞赛 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: ['competition_uid'],
order: [['competition_uid', 'DESC']],
attributes: ['info_value'],
where: { info_key: 'competition_uid' },
});
if (comp && comp.competition_uid) competitionUid = comp.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);
}
@@ -730,6 +817,188 @@ class AdminService extends Service {
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,
};
}
// ==================== 提交记录管理 ====================
/**
@@ -746,7 +1015,10 @@ class AdminService extends Service {
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score'],
// 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']],
});
@@ -817,10 +1089,17 @@ class AdminService extends Service {
const autoScore = subs.reduce((sum, s) => sum + (s.score || 0), 0);
const wordSub = wordMap[String(c.id)];
const hasWord = !!(wordSub && wordSub.word_file_path);
// 正式提交状态:优先以 exam_submissions.submit_time 为准(与选手端 hasSubmitted 同一判定源);
// 历史数据/旧流程未回填 submit_time,但已有逐题答题记录(answeredCount>0)同样视为已提交,
// 与 CSV 导出(answeredCount>0 -> 是)保持一致,避免「试卷管理恒显未提交」的错位。
// 🔴 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),
@@ -829,6 +1108,8 @@ class AdminService extends Service {
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,
@@ -839,8 +1120,10 @@ answeredCount,
hasWordFile: hasWord,
wordFilePath: (wordSub && wordSub.word_file_path) || null,
wordFileName: (wordSub && wordSub.word_file_name) || null,
submitted: !!formalSubmitTime || answeredCount > 0,
submitTime: formalSubmitTime || (subs.length > 0 ? subs[0].created_at : null),
submitted: formalSubmitted,
formalSubmitted,
firstAnswerAt,
submitTime: formalSubmitTime,
// 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致。
// ⚠️ 此前本行与上方 `examScore: c.exam_score ?? 0` 重复定义(对象字面量重复键,后者静默胜出);
// 虽因 updateScores 会同步写 exam_score 而「结果碰巧一致」,但属隐患,故删除上方那条只留本条。
@@ -1024,13 +1307,17 @@ answeredCount,
// 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
const submissions = await this.getExamSubmissions(group);
return {
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '客观题分', '大题分', '总分'],
// 🔴 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.answeredCount > 0 ? '是' : '否',
item.submitted ? '是' : '否',
item.answeredCount ?? 0,
item.examScore ?? 0,
item.wordScore ?? 0,
item.totalScore ?? 0,
@@ -1107,8 +1394,199 @@ answeredCount,
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
// ==================== 批量下载 ZIP ====================
/**
* 成绩公示表导出(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
* 文件重命名为 "组号-学校-姓名-原文件名" 格式
@@ -1685,7 +2163,7 @@ answeredCount,
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'],
attributes: ['id', 'name', 'id_number', 'group', 'score', 'exam_score', 'word_score', 'ppt_score', 'team_id'],
order: [['id', 'ASC']],
raw: true,
});
@@ -1714,6 +2192,17 @@ answeredCount,
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
@@ -1752,9 +2241,13 @@ answeredCount,
item.score = autoScore + item.wordScore;
const es = examMap[String(item.uid)];
item.hasWordFile = !!(es && es.word_file_path);
item.submitted = !!(es && es.submit_time) || answeredCount > 0;
item.submitTime = (es && es.submit_time)
|| (answeredCount > 0 ? (es && es.created_at) || null : null);
// 🔴 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、作品管理口径一致)。
@@ -2014,34 +2507,10 @@ answeredCount,
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
// ==================== 新闻查询 ====================
/**
* 获取所有新闻列表(管理员视图,包含未发布)
* @returns {Promise<Array>} 新闻列表
*/
async listNews() {
const { ctx } = this;
const news = await ctx.model.News.findAll({
order: [['pinned', 'DESC'], ['sort_order', 'ASC'], ['publish_time', 'DESC']],
raw: true,
});
return news.map(n => ({
id: n.id,
title: n.title,
content: n.content,
news_type: n.news_type,
wx_link: n.wx_link,
publish_time: n.publish_time,
pinned: n.pinned,
group: n.group,
tags: n.tags,
sort_order: n.sort_order,
created_at: n.created_at,
updated_at: n.updated_at,
}));
}
// R177:删掉此处一个**死代码版** `listNews()` —— 它无分页、缺 is_draft/附件字段、
// 排序按 sort_order,且在同一个 class 内被下方同名方法覆盖(JS class 后定义胜出)⇒ 永远不执行。
// ⚠️ 新闻列表的唯一生效实现 = 本文件下方的 `async listNews(query)`(分页 + 含 is_draft/附件字段)。
// 保留这行说明是为了防止后人「以为这里还有一份实现」而改到静默无效的位置。
// ==================== 轮播图查询 ====================
@@ -2169,9 +2638,99 @@ answeredCount,
} 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
@@ -2235,6 +2794,11 @@ answeredCount,
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,
});
}
@@ -2257,6 +2821,10 @@ answeredCount,
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;