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