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:
@@ -16,6 +16,12 @@ const { requireTestPassword } = require('../lib/passwordPolicy');
|
||||
// R107(2026-10-12):身份证号前置校验 —— 把「非 18 位」从 Sequelize 的
|
||||
// `Validation len on id_number failed` 变成可读的行级原因;同源口径见 lib/idCard.js。
|
||||
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 contestants = await ctx.model.Contestant.findAll({
|
||||
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']],
|
||||
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) {
|
||||
const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission;
|
||||
const rows = await SubmissionModel.findAll({ order: [['upload_time', 'DESC']], raw: true });
|
||||
@@ -1266,7 +1287,8 @@ answeredCount,
|
||||
const byAgg = {};
|
||||
rows.forEach(r => { (byAgg[r.aggregation_uid] = byAgg[r.aggregation_uid] || []).push(r); });
|
||||
return contestants.map(c => {
|
||||
const subs = byAgg[contestantToAgg[c.id]] || [];
|
||||
const aggUid = contestantToAgg[c.id];
|
||||
const subs = byAgg[aggUid] || [];
|
||||
const files = subs.map(s => ({
|
||||
id: s.id,
|
||||
kind: s.file_type || 'word',
|
||||
@@ -1277,7 +1299,16 @@ answeredCount,
|
||||
uploadedAt: s.upload_time,
|
||||
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,
|
||||
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 条目清单」打成压缩包(单一打包实现,批量与单组共用)。
|
||||
*
|
||||
* @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
|
||||
* 文件重命名为 "组号-学校-姓名-原文件名" 格式
|
||||
*
|
||||
* 🔴 R190 改造:本科/教师组**按小组分文件夹**打包 ——
|
||||
* 旧实现把所有文件平铺在 zip 根下,命名为 `姓名-word-原名`,
|
||||
* 两个文件(Word/PPT)混在一堆同类文件里,评分时无法判断哪两个属于同一小组。
|
||||
* 新结构:`<小组编号>/<原始文件名>`,与管理端「单个小组压缩包」完全同构。
|
||||
* 中职/高职组(Word 答题答案)保持原有平铺命名,不受影响。
|
||||
*
|
||||
* @param {string} group - 组别
|
||||
* @returns {Promise<string>} ZIP 文件路径
|
||||
*/
|
||||
async batchDownloadZip(group) {
|
||||
const { ctx, app } = this;
|
||||
const { app } = this;
|
||||
|
||||
// 获取该组的所有提交记录(考试组与作品组数据形状不同)
|
||||
const isExam = group === 'zhongzhi' || group === 'gaozhi';
|
||||
@@ -1624,14 +1700,32 @@ answeredCount,
|
||||
? await this.getExamSubmissions(group)
|
||||
: await this.getSubmissions(group);
|
||||
|
||||
// 构建临时 ZIP 目录
|
||||
const zipDir = path.join(app.baseDir, 'app/public/tmp/zip', group, String(Date.now()));
|
||||
if (!fs.existsSync(zipDir)) {
|
||||
fs.mkdirSync(zipDir, { recursive: true });
|
||||
// works 列表提供「选手 → 小组目录名」映射(口径与上传落盘同源)
|
||||
const worksList = isExam ? [] : await this.getWorks(group);
|
||||
const folderByContestant = new Map();
|
||||
for (const w of worksList) {
|
||||
folderByContestant.set(String(w.contestantId), w.teamFolder);
|
||||
}
|
||||
|
||||
// 收集所有需要打包的文件
|
||||
const filesToZip = [];
|
||||
const entries = [];
|
||||
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
|
||||
for (const item of submissions) {
|
||||
@@ -1639,66 +1733,107 @@ answeredCount,
|
||||
const userName = (isExam ? (item.userName || '未知') : (account.name_cn || '未知')).replace(/[\\/:*?"<>|]/g, '_');
|
||||
|
||||
if (isExam) {
|
||||
// 中职/高职组:收集 Word 答案文件
|
||||
// 中职/高职组:收集 Word 答案文件(保持原平铺命名)
|
||||
if (item.wordFilePath) {
|
||||
const fullPath = path.join(app.baseDir, 'app/public', item.wordFilePath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
const origName = item.wordFileName || path.basename(fullPath);
|
||||
const targetName = `${userName}-${origName}`;
|
||||
const targetPath = path.join(zipDir, targetName);
|
||||
fs.copyFileSync(fullPath, targetPath);
|
||||
filesToZip.push({ srcPath: fullPath, zipName: targetName });
|
||||
entries.push({ absPath: fullPath, zipName: uniqueZipName(`${userName}-${origName}`) });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 本科/教师组:收集 Word + PPT 作品文件
|
||||
// 本科/教师组:按小组目录归集 Word + PPT
|
||||
const folder = folderByContestant.get(String(item.contestantId)) || '未编组';
|
||||
for (const sub of (item.workSubmissions || [])) {
|
||||
if (sub.file_path) {
|
||||
const fullPath = path.join(app.baseDir, 'app/public', sub.file_path);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
const origName = sub.file_name || path.basename(fullPath);
|
||||
const targetName = `${userName}-${sub.file_type || 'file'}-${origName}`;
|
||||
const targetPath = path.join(zipDir, targetName);
|
||||
fs.copyFileSync(fullPath, targetPath);
|
||||
filesToZip.push({ srcPath: fullPath, zipName: targetName });
|
||||
}
|
||||
}
|
||||
if (!sub.file_path) continue;
|
||||
const fullPath = path.join(app.baseDir, 'app/public', sub.file_path);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
const origName = (sub.file_name || path.basename(fullPath)).replace(/[\\/:*?"<>|]/g, '_');
|
||||
entries.push({ absPath: fullPath, zipName: uniqueZipName(`${folder}/${origName}`) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 ZIP 文件
|
||||
const zipFileName = `${group}_submissions_${Date.now()}.zip`;
|
||||
const zipFilePath = path.join(app.baseDir, 'app/public/tmp/zip', zipFileName);
|
||||
if (entries.length === 0) {
|
||||
const err = new Error('该组暂无已上传的作品文件');
|
||||
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 } });
|
||||
return await this._buildZip(entries);
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
output.on('close', resolve);
|
||||
archive.on('error', reject);
|
||||
/**
|
||||
* 单小组作品压缩包(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);
|
||||
|
||||
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 });
|
||||
// 定位本小组的成员行:有 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];
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 清理临时目录:best-effort(同源缺陷族,与 controller/upload.js:mergeChunks 同修)。
|
||||
// ZIP 已 finalize 落盘,清理失败绝不能把成功的批量下载判成 500
|
||||
// (common-error.log 实证:/api/admin/batch-download/* 曾因此整体失败)。
|
||||
this.ctx.helper.safeCleanup(zipDir, { label: 'batchZip-tmp' });
|
||||
if (entries.length === 0) {
|
||||
const err = new Error('该小组暂无已上传的作品文件');
|
||||
err.bizCode = 'EMPTY';
|
||||
throw err;
|
||||
}
|
||||
|
||||
return zipFilePath;
|
||||
const zipFilePath = await this._buildZip(entries);
|
||||
return {
|
||||
zipFilePath,
|
||||
downloadName: `${folderName}_作品.zip`,
|
||||
folderName,
|
||||
fileCount: entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 评分管理 ====================
|
||||
|
||||
@@ -4,6 +4,11 @@ const Service = require('egg').Service;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const {
|
||||
sanitizeSegment,
|
||||
sanitizeFileName,
|
||||
resolveTeamFolderName,
|
||||
} = require('../lib/team_folder');
|
||||
|
||||
/**
|
||||
* 上传服务 - UploadService
|
||||
@@ -73,68 +78,384 @@ 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 {string} filename - 原始文件名
|
||||
* @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 }
|
||||
*/
|
||||
async saveWorkFile(stream, filename, group) {
|
||||
const { app } = this;
|
||||
|
||||
// 确保上传目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT)
|
||||
const uploadDir = path.join(app.baseDir, 'app/public/works', group);
|
||||
try {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
} catch (mkErr) {
|
||||
this.app.logger.error('[saveWorkFile] mkdir failed', { uploadDir, message: mkErr.message });
|
||||
throw mkErr;
|
||||
}
|
||||
|
||||
// 构建安全文件名
|
||||
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;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.pipe(writeStream);
|
||||
stream.on('data', chunk => { fileSize += chunk.length; });
|
||||
writeStream.on('finish', resolve);
|
||||
stream.on('error', reject);
|
||||
writeStream.on('error', reject);
|
||||
async saveWorkFile(stream, filename, group, folderName, opts = {}) {
|
||||
const { fileType = 'word', aggregationUid = null } = opts;
|
||||
const target = await this.prepareWorkTarget({
|
||||
group, folderName, filename, fileType, aggregationUid,
|
||||
});
|
||||
|
||||
// 🔴 落盘完整性校验(F17-B 防「假成功」):
|
||||
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
|
||||
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size !== fileSize) {
|
||||
// 🔴 清理半截文件用 best-effort(同源缺陷族第三处):
|
||||
// 裸 unlinkSync 一旦失败会抛出「删除失败」,把下面那条真正有诊断价值的
|
||||
// 「文件写入不完整:期望 X 实际 Y」**顶掉**,排障时被引向错误方向。
|
||||
// 清理成不成功都不影响结论——写入不完整这件事必须原样抛出。
|
||||
this.ctx.helper.safeCleanup(filePath, { label: 'partialFile' });
|
||||
throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`);
|
||||
// 临时名:以 `.` 开头 + 固定前缀,便于残留扫描识别;同目录保证 rename 原子
|
||||
const tmpName = `.upload_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
|
||||
+ path.extname(target.fileName);
|
||||
const tmpPath = path.join(target.dir, tmpName);
|
||||
|
||||
const writeStream = fs.createWriteStream(tmpPath);
|
||||
let fileSize = 0;
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.pipe(writeStream);
|
||||
stream.on('data', chunk => { fileSize += chunk.length; });
|
||||
writeStream.on('finish', resolve);
|
||||
stream.on('error', reject);
|
||||
writeStream.on('error', reject);
|
||||
});
|
||||
|
||||
// 🔴 落盘完整性校验(F17-B 防「假成功」):
|
||||
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
|
||||
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
|
||||
const stat = fs.statSync(tmpPath);
|
||||
if (stat.size !== fileSize) {
|
||||
// 🔴 清理半截文件用 best-effort:裸 unlinkSync 一旦失败会抛出「删除失败」,
|
||||
// 把下面那条真正有诊断价值的「文件写入不完整:期望 X 实际 Y」**顶掉**。
|
||||
this.ctx.helper.safeCleanup(tmpPath, { label: 'partialFile' });
|
||||
throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`);
|
||||
}
|
||||
} catch (err) {
|
||||
// 任何失败都不留半截临时文件(best-effort,不掩盖真实错误)
|
||||
this.ctx.helper.safeCleanup(tmpPath, { label: 'saveWorkFile-failed' });
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 计算文件 MD5
|
||||
const md5 = await this._calculateMd5(filePath);
|
||||
|
||||
// 构建相对路径(相对于 app/public/)
|
||||
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
|
||||
|
||||
return {
|
||||
filePath: relativePath,
|
||||
fileName: filename,
|
||||
fileSize,
|
||||
md5,
|
||||
};
|
||||
return await this.commitWorkFile(tmpPath, target);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user