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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user