R194+R195: 小组维度作品列表 / 下载名带小组编号 / includeIdle 口径 / deleteNews 连带清理附件
- service/admin.js
· getScores & getExamSubmissions 支持 includeIdle(默认隐藏「从未参赛」报名者),
成绩导出与批量打包强制全量(includeIdle:true),页面开关不影响导出内容
· 新增 getTeamFolders:小组编号口径统一走 resolveTeamLabels,⛔ 不直接展示 team_id
· 单个/批量下载包内名统一走 buildTeamDownloadName(中职高职 <小组编号>-<姓名>-<原名>)
· 🔴 deleteNews:原实现只 News.destroy 不删盘 ⇒ 每轮公示表测试留 4 个孤儿 xlsx。
改为「先删库后删盘」并连带清理 attachment_url 附件;新增 _removeNewsFile 三重安全闸门
(仅 /public/news/ 单段文件名 + 自产命名前缀 + path.resolve 不越界)与幂等语义,
公示表覆盖写复用同一入口(白名单收窄到 news_file_publicity_)
- lib/team_folder.js:新增 buildTeamDownloadName + MAX_DOWNLOAD_NAME=150(防 Windows 255 上限)
- controller/admin.js:透传 includeIdle;batchDownload 接住 bizCode=EMPTY(业务态返 404 非 500);
中职高职单个大题下载名改走 buildTeamDownloadName
验证:真接口探针 12/12(双清 + 幂等 + 三重闸门);变异 M1(不删盘)1 红、M2(去闸门)3 红,
还原后 12/12 复绿
This commit is contained in:
@@ -21,6 +21,7 @@ const { inspectIdNumber } = require('../lib/idCard');
|
||||
const {
|
||||
resolveTeamFolderName,
|
||||
teamFolderRelative,
|
||||
buildTeamDownloadName,
|
||||
} = require('../lib/team_folder');
|
||||
|
||||
/**
|
||||
@@ -1029,11 +1030,13 @@ class AdminService extends Service {
|
||||
// ==================== 提交记录管理 ====================
|
||||
|
||||
/**
|
||||
* 获取考试答题记录(查询组别对应的提交表)
|
||||
* 获取考试组提交记录(查询组别对应的提交表;中职/高职一行一个选手)
|
||||
* @param {string} group - 组别
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.includeIdle=false] - 是否包含「从未参赛」的报名者(默认隐藏)
|
||||
* @returns {Promise<Array>} 答题记录
|
||||
*/
|
||||
async getExamSubmissions(group) {
|
||||
async getExamSubmissions(group, options = {}) {
|
||||
const { ctx } = this;
|
||||
|
||||
const SubmissionModel = group === 'zhongzhi'
|
||||
@@ -1108,7 +1111,7 @@ class AdminService extends Service {
|
||||
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 rows = contestants.map(c => {
|
||||
// 归集键(含歧义消解后的兜底键);首元素必为主键
|
||||
const candidateKeys = aggKeysByContestant[String(c.id)] || [String(c.id)];
|
||||
const subs = candidateKeys.flatMap(k => submissionsByUser[k] || []);
|
||||
@@ -1157,6 +1160,12 @@ answeredCount,
|
||||
examScore: autoScore,
|
||||
};
|
||||
});
|
||||
|
||||
// 🔴 R195:默认隐藏「从未参赛」的报名者(与 getScores 完全同一判据,⛔ 两边各写一套)。
|
||||
// 「考生答题记录 / 提交记录」表同样一行一个选手,未上场组员会占满屏幕且全为 0。
|
||||
// 需要全量时前端传 includeIdle=1。
|
||||
if (options.includeIdle) return rows;
|
||||
return rows.filter(r => r.answeredCount > 0 || r.hasWordFile || r.formalSubmitted);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1365,7 +1374,9 @@ answeredCount,
|
||||
// 中职/高职组:客观题(系统自动评分) + 综合大题(人工阅卷)
|
||||
// 说明:大题分落库列名为 contestants.word_score(历史沿用,语义已变为「综合大题得分」),
|
||||
// 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
|
||||
const submissions = await this.getExamSubmissions(group);
|
||||
// ⚠️ 导出保持**全量**(含未参赛报名者):成绩导出用于存档/公示核对,
|
||||
// 与页面「默认隐藏未参赛」是两种用途,⛔ 不要让页面开关改变导出内容。
|
||||
const submissions = await this.getExamSubmissions(group, { includeIdle: true });
|
||||
return {
|
||||
// 🔴 R178(B④):原「是否提交」列取 `answeredCount > 0`,与页面/接口口径(submit_time)
|
||||
// 不一致,同一份数据导出与页面显示会互相矛盾。现在拆为「是否交卷」(submit_time)
|
||||
@@ -1679,6 +1690,46 @@ answeredCount,
|
||||
return zipFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量取选手的「小组编号」标签(下载文件名前缀用)
|
||||
*
|
||||
* 口径唯一来源 = `resolveTeamLabels` → `resolveTeamFolderName`:
|
||||
* · `teams.team_number || teams.team_id`(人类可读报名编号)
|
||||
* · 有队伍归属但 teams 表缺元信息 ⇒ `未编组_<teamUid>`(实测中职张明远 team_uid=1 无 teams 行)
|
||||
* · 连队伍归属都没有 ⇒ `未编组_<contestantId>`(每人独立,防撞名)
|
||||
* ⛔ 不可直接展示 / 拼接 `contestants.team_id`(内部数字,与报名编号两套序列)。
|
||||
*
|
||||
* @param {string} group
|
||||
* @param {Array<number|string>} contestantIds
|
||||
* @returns {Promise<Map<string, {teamNumber:?string, teamUid:?number, name:string}>>}
|
||||
*/
|
||||
async getTeamFolders(group, contestantIds) {
|
||||
const { ctx } = this;
|
||||
const ids = (contestantIds || [])
|
||||
.filter(x => x !== null && x !== undefined && String(x).trim() !== '');
|
||||
const out = new Map();
|
||||
if (ids.length === 0) return out;
|
||||
|
||||
const contestants = await ctx.model.Contestant.findAll({
|
||||
where: { id: ids, group },
|
||||
// team_id 供 resolveTeamLabels 在「aggregation.team_uid 缺失」时兜底
|
||||
attributes: ['id', 'name', 'group', 'team_id'],
|
||||
raw: true,
|
||||
});
|
||||
if (contestants.length === 0) return out;
|
||||
|
||||
const labelMap = await resolveTeamLabels(ctx, contestants);
|
||||
for (const c of contestants) {
|
||||
const label = labelMap.get(String(c.id)) || {};
|
||||
out.set(String(c.id), {
|
||||
teamNumber: label.teamNumber || null,
|
||||
teamUid: label.teamUid === undefined ? null : label.teamUid,
|
||||
name: c.name || '',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载某组所有提交文件,打包为 ZIP
|
||||
*
|
||||
@@ -1686,7 +1737,10 @@ answeredCount,
|
||||
* 旧实现把所有文件平铺在 zip 根下,命名为 `姓名-word-原名`,
|
||||
* 两个文件(Word/PPT)混在一堆同类文件里,评分时无法判断哪两个属于同一小组。
|
||||
* 新结构:`<小组编号>/<原始文件名>`,与管理端「单个小组压缩包」完全同构。
|
||||
* 中职/高职组(Word 答题答案)保持原有平铺命名,不受影响。
|
||||
*
|
||||
* 🔴 R195 改造:中职/高职组(Word 大题答案)包内名改为
|
||||
* `<小组编号>-<姓名>-<原文件名>`(原为 `<姓名>-<原文件名>`,小组编号无处体现)。
|
||||
* 格式与单个下载共用 `buildTeamDownloadName`,⛔ 不在此另拼一套。
|
||||
*
|
||||
* @param {string} group - 组别
|
||||
* @returns {Promise<string>} ZIP 文件路径
|
||||
@@ -1697,7 +1751,8 @@ answeredCount,
|
||||
// 获取该组的所有提交记录(考试组与作品组数据形状不同)
|
||||
const isExam = group === 'zhongzhi' || group === 'gaozhi';
|
||||
const submissions = isExam
|
||||
? await this.getExamSubmissions(group)
|
||||
// ⚠️ 打包取**全量**:是否入包由「有无 word 文件」决定,与页面过滤无关。
|
||||
? await this.getExamSubmissions(group, { includeIdle: true })
|
||||
: await this.getSubmissions(group);
|
||||
|
||||
// works 列表提供「选手 → 小组目录名」映射(口径与上传落盘同源)
|
||||
@@ -1707,6 +1762,11 @@ answeredCount,
|
||||
folderByContestant.set(String(w.contestantId), w.teamFolder);
|
||||
}
|
||||
|
||||
// 考试组:小组编号映射(R195)。口径 = resolveTeamLabels,与前端展示 / 落盘目录同源。
|
||||
const teamFolders = isExam
|
||||
? await this.getTeamFolders(group, submissions.map(s => s.contestantId))
|
||||
: new Map();
|
||||
|
||||
const entries = [];
|
||||
const usedNames = new Set();
|
||||
/** 同名条目去重(历史数据里同组两人都传过时会撞名) */
|
||||
@@ -1727,18 +1787,22 @@ answeredCount,
|
||||
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 答案文件(保持原平铺命名)
|
||||
// 中职/高职组:Word 大题答案,平铺,名 = 小组编号-姓名-原文件名(R195)
|
||||
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}`) });
|
||||
const tf = teamFolders.get(String(item.contestantId)) || {};
|
||||
const zipName = buildTeamDownloadName({
|
||||
teamNumber: tf.teamNumber,
|
||||
teamUid: tf.teamUid,
|
||||
aggregationUid: item.contestantId,
|
||||
personName: tf.name || item.userName,
|
||||
originalName: origName,
|
||||
});
|
||||
entries.push({ absPath: fullPath, zipName: uniqueZipName(zipName) });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -2352,11 +2416,13 @@ answeredCount,
|
||||
// ==================== 评分查询 ====================
|
||||
|
||||
/**
|
||||
* 获取某组评分列表
|
||||
* 获取成绩列表(管理端「成绩管理 → 成绩总览」)
|
||||
* @param {string} group - 组别
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.includeIdle=false] - 是否包含「从未参赛」的报名者(默认隐藏)
|
||||
* @returns {Promise<Array>} 评分列表
|
||||
*/
|
||||
async getScores(group) {
|
||||
async getScores(group, options = {}) {
|
||||
const { ctx } = this;
|
||||
const isExam = group === 'zhongzhi' || group === 'gaozhi';
|
||||
const contestants = await ctx.model.Contestant.findAll({
|
||||
@@ -2478,6 +2544,16 @@ answeredCount,
|
||||
});
|
||||
}
|
||||
|
||||
// 🔴 R195:默认隐藏「从未参赛」的报名者。
|
||||
// 中职/高职是**小组参赛**:一组报名多人、实际只一人上场,其余组员在成绩表里
|
||||
// 各占一行 0 分 —— 管理员按小组核对成绩时这些行纯属噪音。
|
||||
// 判据 = 有作答 || 有大题文件 || 已正式交卷(任一即算「参赛过」);
|
||||
// ⛔ 不用「已激活」判断:激活 ≠ 参赛(存在激活后从未进考场的小组)。
|
||||
// 需要全量时前端传 includeIdle=1(「显示未参赛」开关)。
|
||||
// ⚠️ 只对考试组生效:本科/教师的提交判据是「上传作品」,口径不同,不在本次范围。
|
||||
if (isExam && !options.includeIdle) {
|
||||
return list.filter(it => it.answeredCount > 0 || it.hasWordFile || it.formalSubmitted);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -2899,11 +2975,10 @@ answeredCount,
|
||||
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',
|
||||
});
|
||||
// 旧附件清理:复用 _removeNewsFile 的安全闸门,并把可删范围**收窄**到本流程
|
||||
// 自己产出的公示表(`news_file_publicity_` 前缀),绝不误删人工上传的图/文件。
|
||||
if (oldUrl && oldUrl !== fileUrl) {
|
||||
this._removeNewsFile(oldUrl, 'publicity-draft-old', /^news_file_publicity_/);
|
||||
}
|
||||
results.push({ group: g, newsId: exist.id, action: 'updated', fileUrl });
|
||||
} else {
|
||||
@@ -3029,12 +3104,72 @@ answeredCount,
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除新闻
|
||||
* 安全删除 `app/public/news/` 下的**本系统产出**文件(新闻图片 / 新闻附件 / 公示表)。
|
||||
*
|
||||
* 🔴 R195b(2026-10-22):`deleteNews` 原来只 `News.destroy`,**不删盘** ⇒ 每轮公示表
|
||||
* 测试都在 `public/news/` 留下 4 个孤儿 xlsx(实测累积十余个,DB 已无引用)。
|
||||
* 这里把「库/盘双清」收成唯一入口,供 deleteNews 与公示表覆盖写复用。
|
||||
*
|
||||
* 安全闸门(三重,任一不过即跳过并告警):
|
||||
* ① 必须是 `/public/news/<单段文件名>`(拒绝子路径、外域、其它目录);
|
||||
* ② 文件名必须匹配本系统自产的命名前缀(见 controller/admin.js 的三处 upload);
|
||||
* ③ `path.resolve` 归一后必须仍落在 `app/public/news` 内(防 `..` 绕过)。
|
||||
*
|
||||
* ⛔ 绝不删人工放到其它目录的文件;⛔ 不删正文内嵌图(见 deleteNews 的边界说明)。
|
||||
*
|
||||
* @param {string} url - 形如 `/public/news/news_file_1792.xlsx`
|
||||
* @param {string} label - 日志标签
|
||||
* @param {RegExp} [allow] - 允许的 basename 前缀正则(默认覆盖图片+附件+公示表)
|
||||
* @returns {'removed'|'missing'|'skipped'} 与 helper.safeCleanup 同口径(永不抛错)
|
||||
*/
|
||||
_removeNewsFile(url, label = 'deleteNews', allow = /^(news_|news_file_)/) {
|
||||
const { ctx } = this;
|
||||
if (!url || typeof url !== 'string') return 'missing';
|
||||
|
||||
const m = /^\/public\/news\/([^/\\]+)$/.exec(url.trim());
|
||||
if (!m) {
|
||||
ctx.logger.warn('[%s] 拒绝删除:非 news 目录或含子路径(安全闸门): %s', label, url);
|
||||
return 'skipped';
|
||||
}
|
||||
const name = m[1];
|
||||
if (name.indexOf('..') >= 0 || !allow.test(name)) {
|
||||
ctx.logger.warn('[%s] 拒绝删除:非本系统自产命名(安全闸门): %s', label, url);
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
const dir = path.resolve(path.join(this.app.baseDir, 'app/public/news'));
|
||||
const absPath = path.resolve(path.join(dir, name));
|
||||
if (path.dirname(absPath) !== dir) {
|
||||
ctx.logger.warn('[%s] 拒绝删除:解析后越界(安全闸门): %s ⇒ %s', label, url, absPath);
|
||||
return 'skipped';
|
||||
}
|
||||
return ctx.helper.safeCleanup(absPath, { label });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除新闻(连带清理附件文件)
|
||||
*
|
||||
* @param {number} id - 新闻 ID
|
||||
* @returns {Promise<number>} 删除的行数(0 = 本就不存在,幂等)
|
||||
*/
|
||||
async deleteNews(id) {
|
||||
const { ctx } = this;
|
||||
const news = await ctx.model.News.findByPk(id);
|
||||
// 幂等:行不存在与「删了 0 行」同语义,且**不碰磁盘**(避免 id 猜中别人的附件)
|
||||
if (!news) return 0;
|
||||
|
||||
const attachmentUrl = news.attachment_url;
|
||||
await ctx.model.News.destroy({ where: { id } });
|
||||
|
||||
// 🔴 顺序铁律:**先删库后删盘**。库是 SoT —— 若反过来先删盘、再删库失败,就留下
|
||||
// 「DB 有行但文件已没了」的死链(前端点下载 404);而当前顺序即使删盘失败,
|
||||
// 最坏也只是多一个孤儿文件(可被 scan_orphan_files.py 离线发现)。
|
||||
// ⚠️ 已知边界:正文 content 内嵌的 `<img src="/public/news/...">` 不在此清理范围,
|
||||
// 因为无法可靠区分「本系统上传」与「外链/人工插入」;如需彻底治理走孤儿扫描。
|
||||
const fileResult = this._removeNewsFile(attachmentUrl, 'deleteNews');
|
||||
ctx.logger.info('[deleteNews] id=%s 行已删,附件清理=%s url=%s',
|
||||
id, fileResult, attachmentUrl || '(none)');
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user