feat(upload): 组内作品唯一 + 小组编号目录 + 按小组打包下载(R190)

需求(管理人员反馈):
① 本科组/教师组同样遵循「小组内只要有一人上传作品,其他人就不能再上传」;
② 作品按「小组编号」建目录存放并保留原始文件名,管理端下载时按小组打包。

后端落地:
- 组锁:新增 getTeamUploadState/assertTeamUploadAllowed,按 (group, teamUid) 取全组
  提交记录,**最早提交者为归属人**;其他人上传与分片合并两条路径都返 6006
  (data 带 teamLocked/teamOwnerName/teamNumber/folderName)。删除本人作品后自动解锁。
  ⛔ 必须先按 group 过滤再认 teamUid(team_uid 仅组内唯一)。
- 落盘:works/<group>/<小组编号>/<原始文件名>。目录名走新增 lib/team_folder.js
  (唯一 SoT:净化 Windows 非法字符/控制字符/首尾点/设备保留名,限长 60)。
  撞名三态:不存在→原名;同一聚合+同一 file_type→原名原子覆盖;否则 (2)(3)。
  DB file_path 仍为相对路径 ⇒ 存量平铺文件免迁移。
- 直传与分片合并收敛到同一套 prepareWorkTarget + commitWorkFile:先写同目录临时名
  (.upload_*/.merge_*),再 renameSync;失败一律 safeCleanup 后抛。
- status 接口新增 teamLocked/canUpload/teamOwnerName/isTeamOwner/teamFiles/memberCount。
- 管理端:works 列表返回 teamUid/teamNumber/teamFolder(走 resolveTeamLabels 既有口径);
  新增 GET /api/admin/team-zip/:group?teamUid= 单组打包(仅本科/教师,其余 400,包内平铺);
  batch-download 包内改为 <小组编号>/<原始文件名>。两者共用 _buildZip。

验证:真接口 45/45 + 变异 M1(locked=false ⇒ 15 条断言红,还原后复绿);
已收编为门禁第 58 步。
This commit is contained in:
2026-10-21 15:20:42 +08:00
parent de719d8ec6
commit 0a380371ec
7 changed files with 849 additions and 144 deletions

View File

@@ -558,8 +558,60 @@ class AdminController extends Controller {
} }
} }
// ==================== 时间管理 ==================== // ==================== 单小组作品压缩包(R190) ====================
/**
* 下载「单个小组」的作品压缩包
* GET /api/admin/team-zip/:group?teamUid=xx 或 ?aggregationUid=xx
*
* 【业务来源】「管理人员下载作品时将这个文件夹进行压缩,直接下载压缩包」
* 包内**平铺**该小组的文件(zip 本身即小组目录),文件名保留下传时的原名。
*
* @returns {Stream} zip 流
*/
async teamZip() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
// 只有本科/教师组有作品文件(中职/高职组走答题答案通道)
if (!['benke', 'jiaoshi'].includes(group)) {
ctx.body = ctx.helper.error(400, '该组别无作品文件,仅本科组/教师组支持');
return;
}
const zipInfo = await service.admin.teamZip(group, {
teamUid: ctx.query.teamUid,
aggregationUid: ctx.query.aggregationUid,
});
await sendFile(ctx, this.app, {
absolutePath: zipInfo.zipFilePath,
fileName: zipInfo.downloadName,
contentType: 'application/zip',
useXAccel: false,
});
// 下载完成后清理临时 zip(Node 流模式下 res.finish 在流发完后触发)
ctx.res.on('finish', () => {
ctx.helper.safeCleanup(zipInfo.zipFilePath, { label: 'teamZip-zipFile' });
});
} catch (err) {
if (err.bizCode === 'EMPTY') {
ctx.body = ctx.helper.error(404, '该小组暂无已上传的作品文件');
return;
}
if (err.bizCode === 'NOT_FOUND') {
ctx.body = ctx.helper.error(404, err.message || '未找到该小组');
return;
}
ctx.logger.error('小组作品打包失败:', err);
ctx.body = ctx.helper.error(500, '小组作品打包失败');
}
}
// ==================== 时间管理 ====================
/** /**
* 批量设置比赛时间 * 批量设置比赛时间
* PUT /api/admin/times * PUT /api/admin/times

View File

@@ -107,6 +107,25 @@ class UploadController extends Controller {
return; return;
} }
// 🔴 R190 组内上传互斥:本科/教师组「小组中只要有一人上传,其他人就不能上传」,
// 替换权只归组内最早提交者(归属人)。⚠️ 必须放在 ctx.getFileStream() **之前** ——
// 被锁成员根本不该把几十 MB 请求体传上来再被拒(白耗入带宽 + 通道配额)。
const gate = await service.upload.assertTeamUploadAllowed(aggregation, group);
if (!gate.ok) {
// 业务错误 = HTTP 200 + code≠0(helper.error 不改 HTTP 状态),
// 附加 data 供前端在按钮/提示里展示归属人,message 已含完整可读文案。
const body = ctx.helper.error(6006, gate.message);
body.data = {
teamLocked: true,
teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null,
teamNumber: gate.state.teamNumber,
folderName: gate.state.folderName,
};
ctx.body = body;
return;
}
const teamFolder = gate.state.folderName;
// 获取上传的文件流 // 获取上传的文件流
const stream = await ctx.getFileStream(); const stream = await ctx.getFileStream();
if (!stream) { if (!stream) {
@@ -132,15 +151,18 @@ class UploadController extends Controller {
return; return;
} }
// 保存文件 // 获取作品名称与文件类型(流式模式字段在 stream.fields 中)。
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group); // ⚠️ 必须先取再落盘:落盘要用 file_type 判定「同名文件是否同一次提交的新版本」。
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
// 获取作品名称(流式模式字段在 stream.fields 中)
const workName = (stream.fields && stream.fields.work_name) || stream.filename; const workName = (stream.fields && stream.fields.work_name) || stream.filename;
// 文件类型:word / ppt(前端上传时透传)
const fileType = (stream.fields && stream.fields.file_type) || 'word'; const fileType = (stream.fields && stream.fields.file_type) || 'word';
// 保存文件:落点 = works/<group>/<小组编号>/<原始文件名>
const fileInfo = await service.upload.saveWorkFile(
stream, stream.filename, group, teamFolder,
{ fileType, aggregationUid: aggregation.aggregation_uid }
);
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
// 保存提交记录 // 保存提交记录
const record = await service.upload.saveSubmissionRecord( const record = await service.upload.saveSubmissionRecord(
aggregation.aggregation_uid, aggregation.aggregation_uid,
@@ -267,6 +289,8 @@ class UploadController extends Controller {
const { ctx, service, app } = this; const { ctx, service, app } = this;
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。 // 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
let mergedRelPath = null; let mergedRelPath = null;
// 合并临时文件(先写临时名再原子 rename)。失败时需清掉,否则留下 `.merge_*` 残渣。
let mergedTmpPath = null;
let recordPersisted = false; let recordPersisted = false;
try { try {
@@ -293,6 +317,22 @@ class UploadController extends Controller {
return; return;
} }
// 🔴 R190 组内上传互斥:与 upload() 同一个守卫(两条落盘路径都必须拦,
// 否则被锁成员改走分片合并即可绕过组内锁)。
const gate = await service.upload.assertTeamUploadAllowed(aggregation, group);
if (!gate.ok) {
const body = ctx.helper.error(6006, gate.message);
body.data = {
teamLocked: true,
teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null,
teamNumber: gate.state.teamNumber,
folderName: gate.state.folderName,
};
ctx.body = body;
return;
}
const teamFolder = gate.state.folderName;
// 检查分片目录 // 检查分片目录
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId); const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
if (!fs.existsSync(tempDir)) { if (!fs.existsSync(tempDir)) {
@@ -312,16 +352,20 @@ class UploadController extends Controller {
return; return;
} }
// 构建目标文件路径 // 构建落盘目标:小组目录 + 原始文件名(唯一命名实现 = service.prepareWorkTarget,
const uploadDir = path.join(app.baseDir, 'app/public/works', group); // 与 saveWorkFile 同源,⛔ 不得在此另写一份随机命名)。
if (!fs.existsSync(uploadDir)) { const target = await service.upload.prepareWorkTarget({
fs.mkdirSync(uploadDir, { recursive: true }); group,
} folderName: teamFolder,
filename: fileName,
const ext = path.extname(fileName); fileType: file_type || 'word',
const timestamp = Date.now(); aggregationUid: aggregation.aggregation_uid,
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`; });
const targetPath = path.join(uploadDir, safeFileName); // 先合并到临时名,再原子 rename 到最终名 —— 避免管理员在合并过程中下载到半截文件
mergedTmpPath = path.join(
target.dir,
`.merge_${Date.now()}_${Math.random().toString(36).substring(2, 8)}${path.extname(target.fileName)}`
);
// 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额, // 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额,
// 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。 // 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。
@@ -334,8 +378,8 @@ class UploadController extends Controller {
} }
try { try {
// 合并分片 // 合并分片(落到临时名)
const mergeResult = await service.upload.mergeFileChunks(chunkFiles, targetPath); await service.upload.mergeFileChunks(chunkFiles, mergedTmpPath);
// 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。 // 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。
// 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会 // 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会
@@ -343,17 +387,11 @@ class UploadController extends Controller {
// 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。 // 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。
ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' }); ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' });
// 构建相对路径 // 原子落位 + 计算 size/md5(filePath 为相对 app/public 的正斜杠路径)
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/'); const fileInfo = await service.upload.commitWorkFile(mergedTmpPath, target);
mergedRelPath = relativePath; // 供 catch 回滚孤儿文件用 mergedRelPath = fileInfo.filePath; // 供 catch 回滚孤儿文件用
// 保存提交记录 // 保存提交记录
const fileInfo = {
filePath: relativePath,
fileName,
fileSize: mergeResult.fileSize,
md5: mergeResult.md5,
};
const record = await service.upload.saveSubmissionRecord( const record = await service.upload.saveSubmissionRecord(
aggregation.aggregation_uid, aggregation.aggregation_uid,
group, group,
@@ -367,11 +405,11 @@ class UploadController extends Controller {
ctx.body = ctx.helper.success({ ctx.body = ctx.helper.success({
id: record.id, id: record.id,
workName: record.work_name, workName: record.work_name,
filePath: relativePath, filePath: fileInfo.filePath,
fileUrl: ctx.helper.fileUrl(relativePath), fileUrl: ctx.helper.fileUrl(fileInfo.filePath),
fileName, fileName: fileInfo.fileName,
fileSize: mergeResult.fileSize, fileSize: fileInfo.fileSize,
md5: mergeResult.md5, md5: fileInfo.md5,
uploadTime: record.upload_time || new Date().toISOString(), uploadTime: record.upload_time || new Date().toISOString(),
}, '分片合并成功'); }, '分片合并成功');
} finally { } finally {
@@ -382,6 +420,10 @@ class UploadController extends Controller {
if (mergedRelPath && !recordPersisted) { if (mergedRelPath && !recordPersisted) {
service.upload.removePublicFile(mergedRelPath, 'merge-rollback'); service.upload.removePublicFile(mergedRelPath, 'merge-rollback');
} }
// 合并中途失败 ⇒ 临时名文件也必须清掉(best-effort,绝不掩盖真实错误)
if (mergedTmpPath && !mergedRelPath) {
ctx.helper.safeCleanup(mergedTmpPath, { label: 'mergeChunks-failed' });
}
ctx.logger.error('分片合并失败:', err); ctx.logger.error('分片合并失败:', err);
ctx.body = ctx.helper.error(500, '分片合并失败'); ctx.body = ctx.helper.error(500, '分片合并失败');
} }
@@ -448,16 +490,33 @@ class UploadController extends Controller {
return; return;
} }
// 检查提交状态 // 检查提交状态(本人)+ 组内互斥状态(R190)
const submitted = await service.upload.hasSubmitted(aggregation.aggregation_uid, group); const [submitted, teamState, deadlineCheck] = await Promise.all([
service.upload.hasSubmitted(aggregation.aggregation_uid, group),
service.upload.getTeamUploadState(aggregation, group),
service.upload.validateDeadline(group),
]);
// 检查截止时间 // 🔴 组锁状态必须暴露给前端,否则被锁成员会看到「未上传」却任何操作都失败,
const deadlineCheck = await service.upload.validateDeadline(group); // 完全不知道原因(同一类「状态不可见」缺陷在考试组锁上已踩过)。
const canUpload = deadlineCheck.valid && !teamState.locked;
ctx.body = ctx.helper.success({ ctx.body = ctx.helper.success({
submitted, submitted, // 本人是否已上传
deadline: deadlineCheck.deadline, deadline: deadlineCheck.deadline,
canSubmit: deadlineCheck.valid, canSubmit: canUpload, // 兼容旧字段:语义 = 现在能不能上传
canUpload,
deadlineValid: deadlineCheck.valid,
// ---- 小组维度 ----
teamNumber: teamState.teamNumber, // 人类可读小组编号(teams.team_number || team_id)
folderName: teamState.folderName, // 后端落盘用的目录名(前端只展示,不参与命名)
memberCount: teamState.memberCount,
teamSubmitted: teamState.hasAnySubmission, // 组内是否已有人上传
teamOwnerName: teamState.owner ? teamState.owner.name : null,
isTeamOwner: teamState.isOwner,
teamLocked: teamState.locked, // true = 组内别人已上传,本人不能再传
teamLockMessage: teamState.lockMessage,
teamFiles: teamState.teamFiles, // 组内已上传文件清单(含上传者)
}, '获取提交状态成功'); }, '获取提交状态成功');
} catch (err) { } catch (err) {
ctx.logger.error('查询提交状态失败:', err); ctx.logger.error('查询提交状态失败:', err);

View File

@@ -107,6 +107,8 @@ const ROUTE_TABLE = [
['GET', '/api/admin/submissions/:group/:id/word', ['submissions']], ['GET', '/api/admin/submissions/:group/:id/word', ['submissions']],
['GET', '/api/admin/works/:group', ['submissions']], ['GET', '/api/admin/works/:group', ['submissions']],
['GET', '/api/admin/batch-download/:group', ['submissions']], ['GET', '/api/admin/batch-download/:group', ['submissions']],
// R190 单小组作品压缩包:与批量下载同属「作品管理」能力,同权限即可
['GET', '/api/admin/team-zip/:group', ['submissions']],
// ---------- 成绩管理(阅卷/导出/公示表) ---------- // ---------- 成绩管理(阅卷/导出/公示表) ----------
['GET', '/api/admin/scores/:group', ['scores', 'review']], ['GET', '/api/admin/scores/:group', ['scores', 'review']],

132
app/lib/team_folder.js Normal file
View File

@@ -0,0 +1,132 @@
'use strict';
/**
* 小组作品目录命名 —— **单一 SoT**
*
* 【业务来源】R190 管理人员反馈:
* 「参赛小组上传作品时由于他们的作品分为两个文件且会自行命名,所以进行评分时
* 两个文件分开下载经常出现混乱,现在提出了想法,当参赛小组上传作品时,后台
* 自动生成一个以他们小组编号命名的文件夹,将他们的作品存放在这个文件夹中。」
*
* 【本模块的唯一职责】把「一个小组」映射成一个**安全、可读、稳定**的目录名。
* · 上传落盘(service/upload.js)与管理端打包(service/admin.js)**必须**都调这里
* —— 否则「上传写进 A 目录、下载从 B 目录找」的错位会在上线后立刻炸。
* · 前端**不参与**命名,只展示后端回传的 folderName / teamNumber。
*
* 🔴 与既有口径的关系(⛔ 不得另立第二套):
* 小组编号的展示值沿用 `service/admin.js#resolveTeamLabels` 的
* `teams.team_number || teams.team_id`;本模块只负责把它**变成合法目录名**,
* 不重新定义「哪个字段算编号」。
*
* 🔴 为什么必须净化:目录名直接参与 `fs.mkdir` / `path.join`。
* `team_number` 来自报名表导入,历史上出现过带 `/` `\` 的值会被当成路径分隔符
* ⇒ 文件落到意料之外的目录(甚至穿越到 works 之外)。故:
* · 替换 Windows 非法字符 `\ / : * ? " < > |` 与控制字符
* · 去掉首尾空白与首尾点(Windows 不允许尾点,且 `..` 是穿越向量)
* · 限长 60 字符(避免超过单段 255 字节上限,也给中文留余量)
*/
/** 单段目录名/文件名主体的最大长度(字符数,非字节) */
const MAX_SEGMENT = 60;
/** 非法字符:Windows 保留字符 + 控制字符 */
// eslint-disable-next-line no-control-regex
const ILLEGAL_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
/** Windows 设备保留名(不区分大小写,含扩展名前的部分) */
const RESERVED_NAMES = new Set([
'con', 'prn', 'aux', 'nul',
'com0', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
'lpt0', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
]);
/**
* 净化单个路径段(目录名或文件名主体)。
*
* @param {*} raw - 原始值(任意类型,null/undefined 视为空)
* @param {string} [fallback] - 净化后为空时的兜底值
* @returns {string} 安全的单段名称
*/
function sanitizeSegment(raw, fallback = '未命名') {
let s = String(raw === null || raw === undefined ? '' : raw);
s = s.replace(ILLEGAL_CHARS, '_');
// 折叠空白(含全角空格转半角后折叠),去掉首尾空白
s = s.replace(/\u3000/g, ' ').replace(/\s+/g, ' ').trim();
// 去掉首部点(`.` / `..` / `...foo` 都会被误判)与尾部点
s = s.replace(/^\.+/, '').replace(/\.+$/, '').trim();
if (s.length > MAX_SEGMENT) s = s.slice(0, MAX_SEGMENT).trim();
if (!s) return fallback;
// 保留名兜底:`CON` → `CON_`
if (RESERVED_NAMES.has(s.toLowerCase())) return s + '_';
return s;
}
/**
* 净化一个**文件名**(保留扩展名,主体走 sanitizeSegment)。
*
* @param {*} raw - 原始文件名(可能带路径)
* @param {string} [fallbackStem] - 主体为空时的兜底
* @param {string} [fallbackExt] - 无扩展名时的兜底扩展名(含点,如 '.pdf')
* @returns {string} 安全文件名
*/
function sanitizeFileName(raw, fallbackStem = '作品', fallbackExt = '') {
const original = String(raw === null || raw === undefined ? '' : raw);
// 只取 basename:同时切开 `/` 与 `\`(path.basename 在 Linux 上不切 `\`)
const base = original.replace(/^.*[\\/]/, '');
const dot = base.lastIndexOf('.');
const hasExt = dot > 0 && dot < base.length - 1;
const rawStem = hasExt ? base.slice(0, dot) : base;
const rawExt = hasExt ? base.slice(dot + 1) : '';
// 扩展名只允许字母数字,最长 10 位;出现异常字符即视为无扩展名
const ext = /^[A-Za-z0-9]{1,10}$/.test(rawExt) ? '.' + rawExt.toLowerCase() : fallbackExt;
const stem = sanitizeSegment(rawStem, fallbackStem);
return stem + ext;
}
/**
* 解析「小组作品目录名」—— 上传落盘与管理端打包共用的唯一入口。
*
* 三态优先级(与 `resolveTeamLabels` 的 teamNumber 口径对齐):
* ① `teamNumber`(= `teams.team_number || teams.team_id`,人类可读编号,如 BRICS2026-ST-027-94001)
* ② 无编号但有队伍归属 ⇒ `未编组_<teamUid>`(同队成员仍归到同一目录)
* ③ 连队伍归属都没有(单人参赛 / 历史脏数据)⇒ `未编组_<aggregationUid>`(每人独立,防撞车)
*
* @param {Object} p
* @param {?string} p.teamNumber - 人类可读小组编号
* @param {?number} p.teamUid - 队伍号(组内唯一)
* @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底)
* @returns {string} 安全的目录名(保证非空、无路径分隔符)
*/
function resolveTeamFolderName({ teamNumber, teamUid, aggregationUid } = {}) {
const label = teamNumber === null || teamNumber === undefined ? '' : String(teamNumber).trim();
if (label) return sanitizeSegment(label, '未编组');
if (teamUid !== null && teamUid !== undefined && String(teamUid).trim() !== '') {
return sanitizeSegment(`未编组_${teamUid}`, '未编组');
}
if (aggregationUid !== null && aggregationUid !== undefined && String(aggregationUid).trim() !== '') {
return sanitizeSegment(`未编组_${aggregationUid}`, '未编组');
}
return '未编组';
}
/**
* 拼接「小组作品目录」的相对路径(相对 `app/public`,正斜杠)。
*
* @param {string} group - 组别
* @param {string} folderName - resolveTeamFolderName 的返回值
* @returns {string} 如 `works/benke/BRICS2026-ST-027-94001`
*/
function teamFolderRelative(group, folderName) {
return ['works', sanitizeSegment(group, 'unknown'), sanitizeSegment(folderName, '未编组')]
.join('/');
}
module.exports = {
MAX_SEGMENT,
sanitizeSegment,
sanitizeFileName,
resolveTeamFolderName,
teamFolderRelative,
};

View File

@@ -160,6 +160,10 @@ module.exports = app => {
router.get('/api/admin/competition-item', adminAuth, controller.admin.competitionItemInfo); router.get('/api/admin/competition-item', adminAuth, controller.admin.competitionItemInfo);
router.put('/api/admin/competition-item', adminAuth, controller.admin.updateCompetitionItemInfo); router.put('/api/admin/competition-item', adminAuth, controller.admin.updateCompetitionItemInfo);
router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload); router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload);
// 单小组作品压缩包(R190):zip 本身即小组目录,包内平铺该组文件。
// ⚠️ 必须注册在 `/api/admin/works/:group` 之前不是硬要求(路径段数不同,不冲突),
// 但保持「同一业务相邻登记」,避免后来者以为是两条无关通道。
router.get('/api/admin/team-zip/:group', adminAuth, controller.admin.teamZip);
router.get('/api/admin/works/:group', adminAuth, controller.admin.works); router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
// 时间设置 // 时间设置

View File

@@ -16,6 +16,12 @@ const { requireTestPassword } = require('../lib/passwordPolicy');
// R107(2026-10-12):身份证号前置校验 —— 把「非 18 位」从 Sequelize 的 // R107(2026-10-12):身份证号前置校验 —— 把「非 18 位」从 Sequelize 的
// `Validation len on id_number failed` 变成可读的行级原因;同源口径见 lib/idCard.js。 // `Validation len on id_number failed` 变成可读的行级原因;同源口径见 lib/idCard.js。
const { inspectIdNumber } = require('../lib/idCard'); const { inspectIdNumber } = require('../lib/idCard');
// 🔴 R190:小组作品目录命名的**唯一 SoT**。上传落盘(service/upload.js)与本文件的
// 批量/单组打包**必须**用同一个函数算目录名,否则「上传写进 A、下载从 B 找」。
const {
resolveTeamFolderName,
teamFolderRelative,
} = require('../lib/team_folder');
/** /**
* 客观题(选择题 / 判断题)每小题固定分值。 * 客观题(选择题 / 判断题)每小题固定分值。
@@ -1244,11 +1250,26 @@ answeredCount,
const isExam = group === 'zhongzhi' || group === 'gaozhi'; const isExam = group === 'zhongzhi' || group === 'gaozhi';
const contestants = await ctx.model.Contestant.findAll({ const contestants = await ctx.model.Contestant.findAll({
where: { group }, where: { group },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'word_score', 'ppt_score', 'exam_score'], // team_id 供 resolveTeamLabels 在「aggregation.team_uid 缺失」时兜底
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'team_id', 'word_score', 'ppt_score', 'exam_score'],
order: [['id', 'ASC']], order: [['id', 'ASC']],
raw: true, raw: true,
}); });
// 🔴 R190:小组编号 / 小组目录名(口径唯一来源,⛔ 不得在此另算一套)
const labelMap = await resolveTeamLabels(ctx, contestants);
const teamInfoOf = (contestantId, aggUid) => {
const label = labelMap.get(String(contestantId)) || {};
const teamUid = label.teamUid === undefined ? null : label.teamUid;
const teamNumber = label.teamNumber || null;
return {
teamUid,
teamNumber,
// 后端落盘用的目录名;前端只展示,⛔ 不参与拼接
teamFolder: resolveTeamFolderName({ teamNumber, teamUid, aggregationUid: aggUid }),
};
};
if (!isExam) { if (!isExam) {
const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission; const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission;
const rows = await SubmissionModel.findAll({ order: [['upload_time', 'DESC']], raw: true }); const rows = await SubmissionModel.findAll({ order: [['upload_time', 'DESC']], raw: true });
@@ -1266,7 +1287,8 @@ answeredCount,
const byAgg = {}; const byAgg = {};
rows.forEach(r => { (byAgg[r.aggregation_uid] = byAgg[r.aggregation_uid] || []).push(r); }); rows.forEach(r => { (byAgg[r.aggregation_uid] = byAgg[r.aggregation_uid] || []).push(r); });
return contestants.map(c => { return contestants.map(c => {
const subs = byAgg[contestantToAgg[c.id]] || []; const aggUid = contestantToAgg[c.id];
const subs = byAgg[aggUid] || [];
const files = subs.map(s => ({ const files = subs.map(s => ({
id: s.id, id: s.id,
kind: s.file_type || 'word', kind: s.file_type || 'word',
@@ -1277,7 +1299,16 @@ answeredCount,
uploadedAt: s.upload_time, uploadedAt: s.upload_time,
scoreField: s.file_type === 'ppt' ? 'ppt' : 'word', scoreField: s.file_type === 'ppt' ? 'ppt' : 'word',
})); }));
return { contestantId: c.id, name: c.name, idNumber: c.id_number, phone: c.phone, group: c.group, files }; return {
contestantId: c.id,
name: c.name,
idNumber: c.id_number,
phone: c.phone,
group: c.group,
aggregationUid: aggUid === undefined ? null : aggUid,
...teamInfoOf(c.id, aggUid),
files,
};
}); });
} }
@@ -1304,7 +1335,15 @@ answeredCount,
uploadedAt: null, uploadedAt: null,
scoreField: 'word', scoreField: 'word',
}] : []; }] : [];
return { contestantId: c.id, name: c.name, idNumber: c.id_number, phone: c.phone, group: c.group, files }; return {
contestantId: c.id,
name: c.name,
idNumber: c.id_number,
phone: c.phone,
group: c.group,
...teamInfoOf(c.id, null),
files,
};
}); });
} }
@@ -1608,15 +1647,52 @@ answeredCount,
} }
// ==================== 批量下载 ZIP ==================== // ==================== 批量下载 ZIP ====================
/**
* 把「zip 条目清单」打成压缩包(单一打包实现,批量与单组共用)。
*
* @param {Array<{absPath:string, zipName:string}>} entries - 条目清单
* @returns {Promise<string>} zip 绝对路径
* @private
*/
async _buildZip(entries) {
const { app } = this;
// 使用 archiver 打包。archiver v8 为 ESM,导出 ZipArchive 等类;用 new ZipArchive(...) 实例化
const { ZipArchive } = require('archiver');
const zipFilePath = path.join(app.baseDir, 'app/public/tmp/zip', `zip_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.zip`);
fs.mkdirSync(path.dirname(zipFilePath), { recursive: true });
const output = fs.createWriteStream(zipFilePath);
const archive = new ZipArchive({ zlib: { level: 9 } });
await new Promise((resolve, reject) => {
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
// 🔴 直接 file() 到目标条目名,⛔ 不再先 copyFileSync 到临时目录再按目录遍历:
// 复制步骤既多一次全量磁盘写,也会让「重名文件」在临时目录里互相覆盖。
for (const e of entries) {
archive.file(e.absPath, { name: e.zipName });
}
archive.finalize();
});
return zipFilePath;
}
/** /**
* 批量下载某组所有提交文件,打包为 ZIP * 批量下载某组所有提交文件,打包为 ZIP
* 文件重命名为 "组号-学校-姓名-原文件名" 格式 *
* 🔴 R190 改造:本科/教师组**按小组分文件夹**打包 ——
* 旧实现把所有文件平铺在 zip 根下,命名为 `姓名-word-原名`,
* 两个文件(Word/PPT)混在一堆同类文件里,评分时无法判断哪两个属于同一小组。
* 新结构:`<小组编号>/<原始文件名>`,与管理端「单个小组压缩包」完全同构。
* 中职/高职组(Word 答题答案)保持原有平铺命名,不受影响。
* *
* @param {string} group - 组别 * @param {string} group - 组别
* @returns {Promise<string>} ZIP 文件路径 * @returns {Promise<string>} ZIP 文件路径
*/ */
async batchDownloadZip(group) { async batchDownloadZip(group) {
const { ctx, app } = this; const { app } = this;
// 获取该组的所有提交记录(考试组与作品组数据形状不同) // 获取该组的所有提交记录(考试组与作品组数据形状不同)
const isExam = group === 'zhongzhi' || group === 'gaozhi'; const isExam = group === 'zhongzhi' || group === 'gaozhi';
@@ -1624,14 +1700,32 @@ answeredCount,
? await this.getExamSubmissions(group) ? await this.getExamSubmissions(group)
: await this.getSubmissions(group); : await this.getSubmissions(group);
// 构建临时 ZIP 目录 // works 列表提供「选手 → 小组目录名」映射(口径与上传落盘同源)
const zipDir = path.join(app.baseDir, 'app/public/tmp/zip', group, String(Date.now())); const worksList = isExam ? [] : await this.getWorks(group);
if (!fs.existsSync(zipDir)) { const folderByContestant = new Map();
fs.mkdirSync(zipDir, { recursive: true }); for (const w of worksList) {
folderByContestant.set(String(w.contestantId), w.teamFolder);
} }
// 收集所有需要打包的文件 const entries = [];
const filesToZip = []; const usedNames = new Set();
/** 同名条目去重(历史数据里同组两人都传过时会撞名) */
const uniqueZipName = (desired) => {
if (!usedNames.has(desired)) { usedNames.add(desired); return desired; }
const slash = desired.lastIndexOf('/');
const dirPart = slash >= 0 ? desired.slice(0, slash + 1) : '';
const base = slash >= 0 ? desired.slice(slash + 1) : desired;
const dot = base.lastIndexOf('.');
const stem = dot > 0 ? base.slice(0, dot) : base;
const ext = dot > 0 ? base.slice(dot) : '';
for (let i = 2; i <= 99; i += 1) {
const cand = `${dirPart}${stem}-${i}${ext}`;
if (!usedNames.has(cand)) { usedNames.add(cand); return cand; }
}
const cand = `${dirPart}${stem}-${Date.now()}${ext}`;
usedNames.add(cand);
return cand;
};
// 本科/教师组需要 account.name_cn;考试组使用 getExamSubmissions 的 userName // 本科/教师组需要 account.name_cn;考试组使用 getExamSubmissions 的 userName
for (const item of submissions) { for (const item of submissions) {
@@ -1639,66 +1733,107 @@ answeredCount,
const userName = (isExam ? (item.userName || '未知') : (account.name_cn || '未知')).replace(/[\\/:*?"<>|]/g, '_'); const userName = (isExam ? (item.userName || '未知') : (account.name_cn || '未知')).replace(/[\\/:*?"<>|]/g, '_');
if (isExam) { if (isExam) {
// 中职/高职组:收集 Word 答案文件 // 中职/高职组:收集 Word 答案文件(保持原平铺命名)
if (item.wordFilePath) { if (item.wordFilePath) {
const fullPath = path.join(app.baseDir, 'app/public', item.wordFilePath); const fullPath = path.join(app.baseDir, 'app/public', item.wordFilePath);
if (fs.existsSync(fullPath)) { if (fs.existsSync(fullPath)) {
const origName = item.wordFileName || path.basename(fullPath); const origName = item.wordFileName || path.basename(fullPath);
const targetName = `${userName}-${origName}`; entries.push({ absPath: fullPath, zipName: uniqueZipName(`${userName}-${origName}`) });
const targetPath = path.join(zipDir, targetName);
fs.copyFileSync(fullPath, targetPath);
filesToZip.push({ srcPath: fullPath, zipName: targetName });
} }
} }
} else { } else {
// 本科/教师组:收集 Word + PPT 作品文件 // 本科/教师组:按小组目录归集 Word + PPT
const folder = folderByContestant.get(String(item.contestantId)) || '未编组';
for (const sub of (item.workSubmissions || [])) { for (const sub of (item.workSubmissions || [])) {
if (sub.file_path) { if (!sub.file_path) continue;
const fullPath = path.join(app.baseDir, 'app/public', sub.file_path); const fullPath = path.join(app.baseDir, 'app/public', sub.file_path);
if (fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) continue;
const origName = sub.file_name || path.basename(fullPath); const origName = (sub.file_name || path.basename(fullPath)).replace(/[\\/:*?"<>|]/g, '_');
const targetName = `${userName}-${sub.file_type || 'file'}-${origName}`; entries.push({ absPath: fullPath, zipName: uniqueZipName(`${folder}/${origName}`) });
const targetPath = path.join(zipDir, targetName);
fs.copyFileSync(fullPath, targetPath);
filesToZip.push({ srcPath: fullPath, zipName: targetName });
} }
} }
} }
if (entries.length === 0) {
const err = new Error('该组暂无已上传的作品文件');
err.bizCode = 'EMPTY';
throw err;
}
return await this._buildZip(entries);
}
/**
* 单小组作品压缩包(R190)
*
* 【业务来源】「管理人员下载作品时将这个文件夹进行压缩,直接下载压缩包」
* ⇒ 单个小组 = 一个压缩包,包内**平铺**该小组的两个文件(不再套一层目录)。
*
* @param {string} group - benke / jiaoshi
* @param {Object} p
* @param {?number} p.teamUid - 队伍号(优先)
* @param {?number} p.aggregationUid - 聚合报名 ID(无队伍号时的定位键)
* @returns {Promise<{zipFilePath:string, downloadName:string, folderName:string, fileCount:number}>}
*/
async teamZip(group, { teamUid = null, aggregationUid = null } = {}) {
const { app } = this;
const works = await this.getWorks(group);
// 定位本小组的成员行:有 teamUid 用 teamUid(组内全部成员),否则用 aggregationUid
let target;
if (teamUid !== null && teamUid !== undefined && String(teamUid) !== '') {
const members = works.filter(w => String(w.teamUid) === String(teamUid));
if (members.length === 0) {
const err = new Error('未找到该小组');
err.bizCode = 'NOT_FOUND';
throw err;
}
target = members;
} else {
const one = works.find(w => String(w.aggregationUid) === String(aggregationUid));
if (!one) {
const err = new Error('未找到该小组的报名信息');
err.bizCode = 'NOT_FOUND';
throw err;
}
target = [one];
}
const folderName = target[0].teamFolder || '未编组';
const entries = [];
const usedNames = new Set();
for (const w of target) {
for (const f of (w.files || [])) {
if (!f.filePath) continue;
const abs = path.join(app.baseDir, 'app/public', f.filePath);
if (!fs.existsSync(abs)) continue;
let name = (f.fileName || path.basename(abs)).replace(/[\\/:*?"<>|]/g, '_');
if (usedNames.has(name)) {
const dot = name.lastIndexOf('.');
const stem = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let i = 2;
while (usedNames.has(`${stem}-${i}${ext}`)) i += 1;
name = `${stem}-${i}${ext}`;
}
usedNames.add(name);
entries.push({ absPath: abs, zipName: name });
} }
} }
// 创建 ZIP 文件 if (entries.length === 0) {
const zipFileName = `${group}_submissions_${Date.now()}.zip`; const err = new Error('该小组暂无已上传的作品文件');
const zipFilePath = path.join(app.baseDir, 'app/public/tmp/zip', zipFileName); err.bizCode = 'EMPTY';
throw err;
// 使用 archiver 将收集到的文件打包为 ZIP
// archiver v8 为 ESM,导出 ZipArchive 等类;用 new ZipArchive(...) 实例化
const { ZipArchive } = require('archiver');
const output = fs.createWriteStream(zipFilePath);
const archive = new ZipArchive({ zlib: { level: 9 } });
await new Promise((resolve, reject) => {
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
// 将重命名后的文件添加到 ZIP
const copiedFiles = fs.readdirSync(zipDir);
for (const file of copiedFiles) {
const filePath = path.join(zipDir, file);
archive.file(filePath, { name: file });
} }
archive.finalize(); const zipFilePath = await this._buildZip(entries);
}); return {
zipFilePath,
// 🔴 清理临时目录:best-effort(同源缺陷族,与 controller/upload.js:mergeChunks 同修)。 downloadName: `${folderName}_作品.zip`,
// ZIP 已 finalize 落盘,清理失败绝不能把成功的批量下载判成 500 folderName,
// (common-error.log 实证:/api/admin/batch-download/* 曾因此整体失败)。 fileCount: entries.length,
this.ctx.helper.safeCleanup(zipDir, { label: 'batchZip-tmp' }); };
return zipFilePath;
} }
// ==================== 评分管理 ==================== // ==================== 评分管理 ====================

View File

@@ -4,6 +4,11 @@ const Service = require('egg').Service;
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const {
sanitizeSegment,
sanitizeFileName,
resolveTeamFolderName,
} = require('../lib/team_folder');
/** /**
* 上传服务 - UploadService * 上传服务 - UploadService
@@ -73,35 +78,359 @@ class UploadService extends Service {
}; };
} }
// ==================== 小组(队伍)上下文 ====================
/** /**
* 保存作品文件 * 解析当前提交者所属「小组」的上下文 —— 上传落盘与管理端打包共用的唯一解析入口。
*
* 🔴 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)。
* teamUid 解析优先级与 `service/admin.js#resolveTeamLabels` 完全同源:
* `aggregation.team_uid`(报名表聚合,权威)→ `contestants.team_id`(兜底)
*
* @param {Object} aggregation - getUserAggregation() 的返回值(含 include team)
* @param {string} group - 组别
* @returns {Promise<{teamUid:?number, teamNumber:?string, folderName:string, contestant:?Object}>}
*/
async resolveTeamContext(aggregation, group) {
const { ctx } = this;
if (!aggregation) {
return { teamUid: null, teamNumber: null, folderName: resolveTeamFolderName({}), contestant: null };
}
// 选手档案:既用于 team_id 兜底,也用于取姓名做提示文案
const contestant = await ctx.model.Contestant.findByPk(aggregation.account_uid, {
attributes: ['id', 'name', 'group', 'team_id'],
raw: true,
});
let teamUid = aggregation.team_uid;
if (teamUid === null || teamUid === undefined) {
teamUid = (contestant && contestant.team_id !== null && contestant.team_id !== undefined)
? contestant.team_id
: null;
}
// 队伍元信息:优先用 include 进来的 team;缺失时按 (group, teamUid) 回查,
// 避免「有归属无元信息」时把编号退化成兜底名(那会让同队两人的目录名不一致)。
let team = aggregation.team || null;
if (!team && teamUid !== null && teamUid !== undefined) {
team = await ctx.model.Team.findOne({
where: { team_uid: teamUid, group_name: group },
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
}
const teamNumber = team ? (team.team_number || team.team_id || null) : null;
const folderName = resolveTeamFolderName({
teamNumber,
teamUid,
aggregationUid: aggregation.aggregation_uid,
});
return { teamUid, teamNumber, folderName, contestant };
}
/**
* 查询「组内上传状态」—— 本科/教师组的组内互斥判定唯一事实来源。
*
* 【业务规则(R190)】
* ① 同一参赛小组内,只要有任一成员上传过作品,其他成员就不能再上传;
* ② 「替换权」只归**组内最早提交者**(归属人),他在截止前可以随时替换;
* ③ 无队伍归属的独立参赛者不参与组内互斥(自己传自己的)。
*
* ⚠️ 判据必须按 (group, teamUid) 取**全部成员**再数提交记录 —— 旧实现
* `hasSubmitted()` 只数本人的 `aggregation_uid`,同组他人上传后自己仍可继续传,
* 正是本次要修的缺陷。
*
* @param {Object} aggregation - getUserAggregation() 的返回值
* @param {string} group - 组别
* @returns {Promise<Object>} 见下方 result 结构
*/
async getTeamUploadState(aggregation, group) {
const { ctx } = this;
const Op = ctx.model.Sequelize.Op;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const teamCtx = await this.resolveTeamContext(aggregation, group);
const selfAggUid = aggregation.aggregation_uid;
const result = {
teamUid: teamCtx.teamUid,
teamNumber: teamCtx.teamNumber,
folderName: teamCtx.folderName,
memberCount: 1,
hasAnySubmission: false,
owner: null, // { aggregationUid, contestantId, name }
isOwner: false,
locked: false, // true = 组内别人已上传,本人不能再传
lockMessage: null,
teamFiles: [], // [{ fileName, fileType, uploaderName, isMine }]
};
// ---- 无队伍归属:独立参赛,组内锁不成立 ----
if (teamCtx.teamUid === null || teamCtx.teamUid === undefined) {
const mine = await this.hasSubmitted(selfAggUid, group);
result.hasAnySubmission = mine;
result.isOwner = mine;
if (mine) {
result.owner = {
aggregationUid: selfAggUid,
contestantId: aggregation.account_uid,
name: (teamCtx.contestant && teamCtx.contestant.name) || null,
};
}
return result;
}
// ---- 取同组同队的全部成员 ----
// 🔴 必须先按组别过滤再认 teamUid:aggregation.team_uid 是全局序列的弱引用,
// 不加组别限定会把别的组里同号队伍的人算进来(跨组串组,历史上已踩过)。
const aggRows = await ctx.model.Aggregation.findAll({
where: { team_uid: teamCtx.teamUid },
attributes: ['aggregation_uid', 'account_uid'],
raw: true,
});
const candIds = aggRows.map(a => a.account_uid).filter(v => v !== null && v !== undefined);
const memberContestants = candIds.length
? await ctx.model.Contestant.findAll({
where: { id: { [Op.in]: candIds }, group },
attributes: ['id', 'name'],
raw: true,
})
: [];
const nameOf = new Map(memberContestants.map(c => [String(c.id), c.name]));
const groupMemberIds = new Set(memberContestants.map(c => String(c.id)));
const members = aggRows.filter(a => groupMemberIds.has(String(a.account_uid)));
result.memberCount = Math.max(members.length, 1);
// ---- 成员提交记录:按 aggregation_uid 取最早一条,再全局取最早者作归属人 ----
const memberAggUids = members.map(m => m.aggregation_uid);
let firstByAgg = new Map();
if (memberAggUids.length > 0) {
const subs = await SubmissionModel.findAll({
where: { aggregation_uid: { [Op.in]: memberAggUids } },
attributes: ['id', 'aggregation_uid', 'upload_time', 'file_name', 'file_type'],
raw: true,
});
for (const s of subs) {
const t = s.upload_time ? new Date(s.upload_time).getTime() : 0;
const cur = firstByAgg.get(String(s.aggregation_uid));
// 同一 aggregation 有多行(word/ppt)时取最早;同时刻用更小 id 保序,保证可复现
if (!cur || t < cur.t || (t === cur.t && s.id < cur.id)) {
firstByAgg.set(String(s.aggregation_uid), { t, id: s.id, aggUid: s.aggregation_uid });
}
// 组内已上传文件清单:让被锁成员**看得见**「组里已经有什么」,
// 而不是只收到一句「你不能传」。
result.teamFiles.push({
fileName: s.file_name || '',
fileType: s.file_type || 'word',
uploaderName: nameOf.get(String(
(members.find(m => String(m.aggregation_uid) === String(s.aggregation_uid)) || {}).account_uid
)) || null,
isMine: String(s.aggregation_uid) === String(selfAggUid),
});
}
// 稳定排序:word 在前、ppt 在后,便于前端固定展示顺序
result.teamFiles.sort((a, b) => String(a.fileType).localeCompare(String(b.fileType)));
}
result.hasAnySubmission = firstByAgg.size > 0;
let best = null;
for (const v of firstByAgg.values()) {
if (!best || v.t < best.t || (v.t === best.t && v.id < best.id)) best = v;
}
if (best) {
const ownerMember = members.find(m => String(m.aggregation_uid) === String(best.aggUid));
const ownerContestantId = ownerMember ? ownerMember.account_uid : null;
result.owner = {
aggregationUid: best.aggUid,
contestantId: ownerContestantId,
name: ownerContestantId !== null ? (nameOf.get(String(ownerContestantId)) || null) : null,
};
result.isOwner = String(best.aggUid) === String(selfAggUid);
result.locked = !result.isOwner;
if (result.locked) {
const who = result.owner.name || '同组其他成员';
result.lockMessage =
`您的小组已由「${who}」上传作品。为保持组内作品唯一,`
+ `只有「${who}」可在截止时间前替换文件,其他成员不能再上传。`
+ `如需调整,请联系「${who}」处理。`;
}
}
return result;
}
/**
* 组内互斥守卫 —— 上传/合并两条路径**都必须**先过这里。
*
* @param {Object} aggregation
* @param {string} group
* @returns {Promise<{ok:boolean, message:string, state:Object}>}
*/
async assertTeamUploadAllowed(aggregation, group) {
const state = await this.getTeamUploadState(aggregation, group);
if (state.locked) {
return { ok: false, message: state.lockMessage, state };
}
return { ok: true, message: '', state };
}
// ==================== 作品落盘 ====================
/**
* 确保小组作品目录存在并返回绝对路径。
*
* @param {string} group - 组别
* @param {string} folderName - 小组目录名
* @returns {string} 目录绝对路径
*/
ensureWorkDir(group, folderName) {
const folder = sanitizeSegment(folderName, '未编组');
const dir = path.join(this.app.baseDir, 'app/public/works', group, folder);
try {
// 无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT
fs.mkdirSync(dir, { recursive: true });
} catch (mkErr) {
this.app.logger.error('[ensureWorkDir] mkdir failed', { dir, message: mkErr.message });
throw mkErr;
}
return dir;
}
/**
* 计算作品落盘目标(目录 + 最终文件名)—— 上传与分片合并共用的**唯一**命名实现。
*
* 🔴 为什么必须收敛:改造前 `service/upload.js#saveWorkFile` 与
* `controller/upload.js#mergeChunks` 各写了一份 `work_<ts>_<rand><ext>` 随机命名,
* 两处一旦漂移就会出现「合并落 A、下载找 B」。现统一到本方法。
*
* 落点:`app/public/works/<group>/<小组编号>/<原始文件名>`
*
* @param {Object} p
* @param {string} p.group - 组别
* @param {string} p.folderName - 小组目录名(resolveTeamFolderName)
* @param {string} p.filename - 上传时的原始文件名
* @param {string} [p.fileType] - word / ppt
* @param {?number} [p.aggregationUid] - 提交者聚合 ID(同名文件归属判定用)
* @returns {Promise<{dir:string, folder:string, fileName:string, absPath:string, relPath:string}>}
*/
async prepareWorkTarget({ group, folderName, filename, fileType = 'word', aggregationUid = null }) {
const dir = this.ensureWorkDir(group, folderName);
const folder = sanitizeSegment(folderName, '未编组');
const fileName = await this._resolveTargetFileName({
dir, group, folder, filename, fileType, aggregationUid,
});
const relPath = ['works', group, folder, fileName].join('/');
return { dir, folder, fileName, absPath: path.join(dir, fileName), relPath };
}
/**
* 决定小组目录内的最终文件名。
*
* 【规则】保留上传时的原始文件名(用户裁定);仅在**确实会覆盖他人文件**时才加序号。
* ① 目标不存在 ⇒ 直接用原名
* ② 目标存在,且 DB 里同路径记录属于**同一聚合、同一 file_type** ⇒ 是同一次提交的新版本
* ⇒ 用原名(走原子 rename 覆盖,`saveSubmissionRecord` 会清理旧路径)
* ③ 目标存在但查不到记录 / 属于别人 / 属不同 file_type ⇒ 加 `(2)(3)…` 序号,绝不覆盖
*
* @private
*/
async _resolveTargetFileName({ dir, group, folder, filename, fileType, aggregationUid }) {
const { ctx } = this;
const baseName = sanitizeFileName(filename);
const abs = path.join(dir, baseName);
if (!fs.existsSync(abs)) return baseName;
const rel = ['works', group, folder, baseName].join('/');
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const row = await SubmissionModel.findOne({
where: { file_path: rel },
attributes: ['id', 'aggregation_uid', 'file_type'],
raw: true,
});
const sameLogicalFile = row
&& String(row.file_type || 'word') === String(fileType || 'word')
&& (aggregationUid === null || String(row.aggregation_uid) === String(aggregationUid));
if (sameLogicalFile) return baseName;
// 撞名且不属同一逻辑文件 ⇒ 加序号,保护已在使用的文件
const dot = baseName.lastIndexOf('.');
const stem = dot > 0 ? baseName.slice(0, dot) : baseName;
const ext = dot > 0 ? baseName.slice(dot) : '';
for (let i = 2; i <= 99; i += 1) {
const candidate = `${stem}(${i})${ext}`;
if (!fs.existsSync(path.join(dir, candidate))) return candidate;
}
// 极端兜底:用时间戳确保唯一(不抛错,宁可名字丑也不丢文件)
return `${stem}_${Date.now()}${ext}`;
}
/**
* 把临时文件原子地落到最终位置,并补齐 size / md5。
*
* 🔴 为什么要先写临时名再 rename:直接写最终文件时,若恰好有管理员在下载,
* 会读到**半截文件**(zip 里就是损坏的 PDF)。同目录 rename 在同一文件系统内
* 是原子操作,读方要么看到旧的完整文件、要么看到新的完整文件。
*
* @param {string} tmpPath - 临时文件绝对路径(必须与目标同目录)
* @param {Object} target - prepareWorkTarget() 的返回值
* @returns {Promise<{filePath:string, fileName:string, fileSize:number, md5:string}>}
*/
async commitWorkFile(tmpPath, target) {
try {
fs.renameSync(tmpPath, target.absPath);
} catch (renameErr) {
this.ctx.helper.safeCleanup(tmpPath, { label: 'commitWorkFile-tmp' });
throw renameErr;
}
const stat = fs.statSync(target.absPath);
const md5 = await this._calculateMd5(target.absPath);
return {
filePath: target.relPath,
fileName: target.fileName,
fileSize: stat.size,
md5,
};
}
/**
* 保存作品文件(单次流式上传)
*
* @param {ReadableStream} stream - 文件流 * @param {ReadableStream} stream - 文件流
* @param {string} filename - 原始文件名 * @param {string} filename - 原始文件名
* @param {string} group - 组别 * @param {string} group - 组别
* @param {string} folderName - 小组目录名(resolveTeamFolderName 的返回值)
* @param {Object} [opts]
* @param {string} [opts.fileType] - word / ppt
* @param {?number} [opts.aggregationUid] - 提交者聚合 ID
* @returns {Promise<Object>} { filePath, fileName, fileSize, md5 } * @returns {Promise<Object>} { filePath, fileName, fileSize, md5 }
*/ */
async saveWorkFile(stream, filename, group) { async saveWorkFile(stream, filename, group, folderName, opts = {}) {
const { app } = this; const { fileType = 'word', aggregationUid = null } = opts;
const target = await this.prepareWorkTarget({
group, folderName, filename, fileType, aggregationUid,
});
// 确保上传目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT) // 临时名:以 `.` 开头 + 固定前缀,便于残留扫描识别;同目录保证 rename 原子
const uploadDir = path.join(app.baseDir, 'app/public/works', group); const tmpName = `.upload_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
try { + path.extname(target.fileName);
fs.mkdirSync(uploadDir, { recursive: true }); const tmpPath = path.join(target.dir, tmpName);
} catch (mkErr) {
this.app.logger.error('[saveWorkFile] mkdir failed', { uploadDir, message: mkErr.message });
throw mkErr;
}
// 构建安全文件名 const writeStream = fs.createWriteStream(tmpPath);
const ext = path.extname(filename);
const timestamp = Date.now();
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`;
const filePath = path.join(uploadDir, safeFileName);
// 将文件流写入磁盘(用 pipe 自动处理背压,避免大文件内存暴涨 / 写入被截断)
const writeStream = fs.createWriteStream(filePath);
let fileSize = 0; let fileSize = 0;
try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
stream.pipe(writeStream); stream.pipe(writeStream);
stream.on('data', chunk => { fileSize += chunk.length; }); stream.on('data', chunk => { fileSize += chunk.length; });
@@ -113,28 +442,20 @@ class UploadService extends Service {
// 🔴 落盘完整性校验(F17-B 防「假成功」): // 🔴 落盘完整性校验(F17-B 防「假成功」):
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败, // 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。 // 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
const stat = fs.statSync(filePath); const stat = fs.statSync(tmpPath);
if (stat.size !== fileSize) { if (stat.size !== fileSize) {
// 🔴 清理半截文件用 best-effort(同源缺陷族第三处): // 🔴 清理半截文件用 best-effort:裸 unlinkSync 一旦失败会抛出「删除失败」,
// 裸 unlinkSync 一旦失败会抛出「删除失败」,把下面那条真正有诊断价值的 // 把下面那条真正有诊断价值的「文件写入不完整:期望 X 实际 Y」**顶掉**。
// 「文件写入不完整:期望 X 实际 Y」**顶掉**,排障时被引向错误方向。 this.ctx.helper.safeCleanup(tmpPath, { label: 'partialFile' });
// 清理成不成功都不影响结论——写入不完整这件事必须原样抛出。
this.ctx.helper.safeCleanup(filePath, { label: 'partialFile' });
throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`); throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`);
} }
} catch (err) {
// 任何失败都不留半截临时文件(best-effort,不掩盖真实错误)
this.ctx.helper.safeCleanup(tmpPath, { label: 'saveWorkFile-failed' });
throw err;
}
// 计算文件 MD5 return await this.commitWorkFile(tmpPath, target);
const md5 = await this._calculateMd5(filePath);
// 构建相对路径(相对于 app/public/)
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
return {
filePath: relativePath,
fileName: filename,
fileSize,
md5,
};
} }
/** /**