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:
2026-10-22 09:41:51 +08:00
parent 0a380371ec
commit 78f34ea9f5
3 changed files with 253 additions and 24 deletions

View File

@@ -15,6 +15,9 @@ const { recordAudit, ACTIONS: AUDIT_ACTIONS } = require('../lib/auditLog');
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
const { requireTestPassword } = require('../lib/passwordPolicy');
// 🔴 R195:下载文件名的「小组编号」前缀与拼接规则唯一 SoT(与落盘目录名同源)。
// 中职/高职大题单个下载与批量 ZIP 包内名**必须**共用 buildTeamDownloadName。
const { buildTeamDownloadName } = require('../lib/team_folder');
/**
* 管理控制器 - AdminController
@@ -417,9 +420,12 @@ class AdminController extends Controller {
if (!this._validateGroup(group, true)) return;
// 中职/高职组使用简化版查询(contestants + exam_submissions)
// R195:默认只返回「参赛过的人」(有作答 / 有大题文件 / 已交卷);
// `?includeIdle=1` 才返回全部报名者(前端「显示未参赛」开关)。
const isExamGroup = group === 'zhongzhi' || group === 'gaozhi';
const includeIdle = ctx.query.includeIdle === '1' || ctx.query.includeIdle === 'true';
const submissions = isExamGroup
? await service.admin.getExamSubmissions(group)
? await service.admin.getExamSubmissions(group, { includeIdle })
: await service.admin.getSubmissions(group);
ctx.body = ctx.helper.success({
group,
@@ -546,6 +552,17 @@ class AdminController extends Controller {
ctx.helper.safeCleanup(path.join(zipRoot, group), { label: 'batchDownload-zipDir' });
});
} catch (err) {
// 🔴 R195 修既有缺陷:「该组一个已上传文件都没有」是**正常业务态**,不是服务故障。
// `service.admin.batchDownloadZip` 为此专门抛 `bizCode:'EMPTY'`,但这里原先没有分支
// 接住它 ⇒ 落到兜底 500「批量下载失败」,管理端完全看不出真实原因,
// 且每点一次下载就刷一条 ERROR 日志(噪音淹没真故障)。
// ⚠️ 不能用 HTTP 4xx:本项目约定 `helper.error()` 不改状态码,业务错 = 200 + code≠0。
if (err && err.bizCode === 'EMPTY') {
ctx.logger.warn(`[batchDownload] 组 ${ctx.params.group} 暂无已上传文件(业务 404)`);
ctx.body = ctx.helper.error(404, err.message || '该组暂无已上传的作品文件');
return;
}
ctx.logger.error('批量下载失败:', err);
// 如果 archiver 不可用,返回友好错误
@@ -794,7 +811,10 @@ class AdminController extends Controller {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
const scores = await service.admin.getScores(group);
// R195:默认隐藏「从未参赛」的报名者(中职/高职按小组参赛,未上场组员全 0 分占行);
// `?includeIdle=1` 返回全量(前端「显示未参赛」开关)。
const includeIdle = ctx.query.includeIdle === '1' || ctx.query.includeIdle === 'true';
const scores = await service.admin.getScores(group, { includeIdle });
ctx.body = ctx.helper.success({ list: scores });
} catch (err) {
ctx.logger.error('获取评分列表失败:', err);
@@ -1600,8 +1620,23 @@ class AdminController extends Controller {
return;
}
const fileName = wordFileName || path.basename(filePath);
filePathCache.setFull(wordCacheKey, wordFilePath, wordFileName);
let fileName = wordFileName || path.basename(filePath);
if (isExamGroup) {
// 🔴 R195:中职/高职大题下载名 = `<小组编号>-<姓名>-<原文件名>`
// 小组编号口径走 resolveTeamLabels(teamNumber → 未编组_<uid>),
// 与「选手管理 / 提交记录」列表展示、作品落盘目录**同源**,⛔ 不取 contestants.team_id。
// 缓存里存的是**最终名**,故命中缓存时无需再查库。
const teamFolders = await service.admin.getTeamFolders(group, [id]);
const tf = teamFolders.get(String(id)) || {};
fileName = buildTeamDownloadName({
teamNumber: tf.teamNumber,
teamUid: tf.teamUid,
aggregationUid: id,
personName: tf.name,
originalName: fileName,
});
}
filePathCache.setFull(wordCacheKey, wordFilePath, fileName);
// 🔴 X-Accel-Redirect 根治:生产经 nginx 内部重定向吐文件,开发回退流式。
// 文件名编码与路径穿越防护、文件 IO 并发护栏统一在 _sendFile 内处理(见其注释)。
await this._sendFile(