Files
competition-website/app/service/upload.js
qubiaobiao 0a380371ec 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 步。
2026-10-21 15:20:42 +08:00

802 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
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
*
* 封装作品文件上传相关业务逻辑,包括截止时间校验、文件保存、
* 分片上传与合并等功能。适用于本科组和教师组的作品提交。
*/
class UploadService extends Service {
/**
* 获取用户的聚合报名信息
* @param {number} accountUid - 账号 UID
* @returns {Promise<Object|null>} 聚合报名记录
*/
async getUserAggregation(accountUid) {
const { ctx } = this;
return await ctx.model.Aggregation.findOne({
where: { account_uid: accountUid },
include: [
{
model: ctx.model.Team,
as: 'team',
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
},
],
});
}
/**
* 验证提交截止时间
* @param {string} group - 组别:benke / jiaoshi
* @returns {Promise<Object>} { valid, message, deadline }
*/
async validateDeadline(group) {
const { ctx } = this;
const timeConfig = await ctx.model.CompetitionTime.findOne({
where: { group },
});
// 权威截止时间 = end_time(管理后台「提交截止时间」输入框绑定的正是该列)。
// 历史遗留的 deadline 列仅作后备,避免出现「状态标签(按 end_time)显示进行中、
// 上传却按旧 deadline 判定已截止」的两条时间线不一致问题。
const effective = (timeConfig && (timeConfig.end_time || timeConfig.deadline)) || null;
if (!effective) {
return {
valid: false,
message: '提交截止时间尚未设置',
deadline: null,
};
}
const now = new Date();
const deadline = new Date(effective);
if (now > deadline) {
return {
valid: false,
message: '提交已截止',
deadline: deadline.toISOString(),
};
}
return {
valid: true,
message: '可以提交',
deadline: deadline.toISOString(),
};
}
// ==================== 小组(队伍)上下文 ====================
/**
* 解析当前提交者所属「小组」的上下文 —— 上传落盘与管理端打包共用的唯一解析入口。
*
* 🔴 小组键 = (组别 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, folderName, opts = {}) {
const { fileType = 'word', aggregationUid = null } = opts;
const target = await this.prepareWorkTarget({
group, folderName, filename, fileType, aggregationUid,
});
// 临时名:以 `.` 开头 + 固定前缀,便于残留扫描识别;同目录保证 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;
}
return await this.commitWorkFile(tmpPath, target);
}
/**
* 合并文件分片
* @param {Array} chunks - 分片文件路径数组(按索引排序)
* @param {string} targetPath - 目标文件路径
* @returns {Promise<Object>} { filePath, fileName, fileSize, md5 }
*/
async mergeFileChunks(chunks, targetPath) {
// 确保目标目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT)
const targetDir = path.dirname(targetPath);
try {
fs.mkdirSync(targetDir, { recursive: true });
} catch (mkErr) {
this.app.logger.error('[mergeFileChunks] mkdir failed', { targetDir, message: mkErr.message });
throw mkErr;
}
// 按顺序合并所有分片。
// 🔴 并发要点(2026-08-10 修):这里过去用 fs.readFileSync 把整个分片同步读进内存,
// 再 writeStream.write(buf) 且不等 drain。后果有二:
// ① 同步读盘会「冻结 event loop」——一个 50MB 文件合并期间,同一 worker 上
// 其它考生的上传/答题请求全部排队,几十人同时交卷时表现为集体卡死;
// ② 不理会背压,N 个分片 Buffer 全堆在写队列里,单次合并瞬时吃掉整份文件的内存。
// 现改为逐分片流式 pipe({ end: false } 续写),全程异步 + 自动背压,
// 内存占用恒定在一个 highWaterMark(64KB),event loop 不再被阻塞。
//
// 🔴 监听器修复(2026-08-11):原实现在「循环内」对每个分片都执行
// `writeStream.on('error', reject)`,单一 writeStream 会被挂上 ≈分片数 个
// error 监听(10 分片 → 11 个),极端并发下触发 MaxListenersExceededWarning
// 并拖垮单 worker。现改为 writeStream.error 只挂【一次】,并用 settled 防重入。
let totalSize = 0;
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(targetPath);
let settled = false;
const fail = (err) => {
if (settled) return;
settled = true;
writeStream.destroy();
reject(err);
};
// 仅挂一次:writeStream 自身的错误
writeStream.on('error', fail);
(async () => {
try {
for (const chunkPath of chunks) {
await new Promise((res, rej) => {
const readStream = fs.createReadStream(chunkPath);
// 每个 readStream 是独立 emitter,挂一个 error 监听是合理的
readStream.on('error', rej);
readStream.on('data', buf => { totalSize += buf.length; });
readStream.on('end', res);
readStream.pipe(writeStream, { end: false });
});
// 删除已合并的分片文件(异步,避免同步 unlink 累积阻塞)
await fs.promises.unlink(chunkPath).catch(() => {});
}
writeStream.end();
writeStream.on('finish', () => {
if (settled) return;
settled = true;
resolve();
});
} catch (e) {
fail(e);
}
})();
}).catch((mergeErr) => {
// 清理半截目标文件,绝不把不完整文件记为成功(F17-B 防假成功)
return fs.promises.unlink(targetPath).catch(() => {}).then(() => { throw mergeErr; });
});
// 落盘完整性校验:合并后字节数必须与读入总量一致
const mergedStat = await fs.promises.stat(targetPath);
if (mergedStat.size !== totalSize) {
await fs.promises.unlink(targetPath).catch(() => {});
throw new Error(`分片合并不完整:期望 ${totalSize} 字节,实际 ${mergedStat.size} 字节`);
}
// 计算合并后文件的 MD5
const md5 = await this._calculateMd5(targetPath);
const fileName = path.basename(targetPath);
return {
filePath: targetPath,
fileName,
fileSize: totalSize,
md5,
};
}
/**
* 计算文件 MD5 值
* @param {string} filePath - 文件路径
* @returns {Promise<string>} MD5 哈希值
*/
async _calculateMd5(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* 保存作品提交记录到数据库
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @param {Object} fileInfo - 文件信息 { filePath, fileName, fileSize, md5 }
* @param {string} workName - 作品名称(可选)
* @returns {Promise<Object>} 保存的记录
*/
async saveSubmissionRecord(aggregationUid, group, fileInfo, workName = '', fileType = 'word') {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 按 (aggregation_uid, file_type) 定位记录:Word / PPT 各自独立存储,
// 避免后上传的文件覆盖先上传的文件(修复 PPT 顶掉 Word 的问题)。
const existing = await SubmissionModel.findOne({
where: { aggregation_uid: aggregationUid, file_type: fileType },
});
const payload = {
work_name: workName || fileInfo.fileName,
file_path: fileInfo.filePath,
file_name: fileInfo.fileName,
file_size: fileInfo.fileSize,
file_md5: fileInfo.md5,
upload_time: new Date(),
file_type: fileType,
};
if (existing) {
// 🔴 C9 根因① 修复(2026-09-29 · R45):重传覆盖前,先记住旧物理文件路径,
// 待 DB 更新**成功之后**再删旧文件 —— 否则每次重传都把旧文件永久留在磁盘上。
// 实证:本次清理的 1003 个孤儿(9.62 GB)中,绝大多数正是这样累积出来的。
//
// ⚠️ 顺序不可颠倒:若先删旧文件、而 update 又失败,DB 仍然指向**已被删除**的旧路径
// ⇒ 管理端/考生端下载 404(这是**数据丢失**,比留下一个孤儿严重得多)。
// 先落库后删文件,最坏结果只是多一个可被兜底扫描捞回来的孤儿。
const stalePath = (existing.file_path && existing.file_path !== fileInfo.filePath)
? existing.file_path
: null;
await existing.update({
...payload,
score: null, // 重新上传后清除评分
});
if (stalePath) {
// best-effort:删不掉只告警,绝不影响「上传成功」这个业务结果
const r = this.removePublicFile(stalePath, 'overwrite-stale');
// 🔴 R157:登记计数(⛔ 不抛错,保持 best-effort),使「旧文件清理失败」
// 在运行期可被读取,而不只沉在日志里。
this._noteDeleteResult(r, stalePath, 'overwrite-stale');
if (r !== 'removed') {
this.app.logger.warn(
`[saveSubmissionRecord] 旧文件未能清理,已成为孤儿(等待兜底扫描 script/scan_orphan_files.py): ${stalePath} -> ${r}`
);
}
}
return existing;
}
return await SubmissionModel.create({
aggregation_uid: aggregationUid,
...payload,
});
}
/**
* 查询用户的提交状态
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @returns {Promise<boolean>} 是否已提交
*/
async hasSubmitted(aggregationUid, group) {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const count = await SubmissionModel.count({
where: { aggregation_uid: aggregationUid },
});
return count > 0;
}
/**
* 获取用户的提交历史列表
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @returns {Promise<Array>} 提交记录列表
*/
async getSubmissions(aggregationUid, group) {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
return await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
order: [['upload_time', 'DESC']],
include: [
{
model: ctx.model.Aggregation,
as: 'aggregation',
attributes: ['aggregation_uid', 'team_uid', 'account_uid', 'identity'],
},
],
});
}
/**
* 删除用户的提交记录及其物理文件
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @param {number} id - 提交记录 ID
* @returns {Promise<boolean>} 删除成功返回 true,未找到(或无权限)返回 false
*/
async deleteSubmission(aggregationUid, group, id) {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 仅允许删除本人名下的记录(聚合键强约束,防越权)
const record = await SubmissionModel.findOne({
where: { id, aggregation_uid: aggregationUid },
});
if (!record) return false;
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
// 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径,
// 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。
//
// 🔴 R157:删除结果**不再丢弃**。此前返回值被完全忽略 ⇒ 删除失败只进日志,
// 运行期无计数、无暴露,孤儿文件在服务侧完全不可见(只能靠离线扫描发现,
// 而离线扫描报出的孤儿无法回溯「是哪一次删除失败的」)。
// 修法:累计到**服务实例计数器 + 暴露 getter**,⛔ 不抛错(保持 best-effort 语义)。
if (record.file_path) {
const r = this.removePublicFile(record.file_path, 'deleteSubmission');
this._noteDeleteResult(r, record.file_path, 'deleteSubmission');
}
await record.destroy();
return true;
}
/**
* 删除 app/public 下的物理文件(统一的安全删除入口)
*
* 【为什么需要它】C9 根因治理(2026-09-29 · R45):此前「删作品文件」这件事在
* service/upload.js、controller/admin.js、controller/exam.js 各写了一遍裸
* `fs.unlinkSync`,既没有路径白名单、失败也只在个别处记日志 ⇒
* 一旦删除失败就静默留下一份无人认领的文件(孤儿)。
* 现收敛为**唯一入口**:所有「按 DB 里的相对路径删文件」都必须经过这里。
*
/**
* 【安全闸门】只允许删除 `works/` 与 `tmp/` 前缀下的文件:
* - DB 里的 file_path 天然是 `works/<group>/<file>`;前缀白名单可挡住
* 「脏数据 / 手工改库 / SQL 注入」把 `file_path` 指向 `templates/`、`carousel/`
* 等静态资源时被连带删除的风险。
* - 含 `..` 的路径一律拒绝(防目录穿越)。
*
* 【失败语义】best-effort,**永不抛错**:删除是副作用,不能污染主业务结果
* (详见 extend/helper.js:safeCleanup 的说明)。
*
* @param {string} relativePath - 相对 app/public 的路径,如 `works/benke/work_x.pdf`
* @param {string} [label] - 日志场景名
* @returns {'removed'|'missing'|'skipped'} 删除结果(不抛错)
*/
/**
* 【R157】登记一次删除结果 —— 把「删除失败」从**只有日志**变成**可计数、可读取**。
*
* 【为什么不抛错】删除是副作用,失败不得污染主业务结果(best-effort 语义见
* extend/helper.js:safeCleanup)。但「不抛错」⛔ 不等于「不可见」——
* 此前 removed/skipped/missing 三态被调用方直接丢弃,导致孤儿文件在运行期
* 完全不可见。这里累计计数并暴露 `delStats` / `getDeleteStats()`,
* 供健康检查、探针与门禁读取。
*
* @param {'removed'|'missing'|'skipped'} r - removePublicFile 的返回值
* @param {string} rel - 相对路径(仅用于日志定位)
* @param {string} label - 场景名
* @returns {{removed:number,skipped:number,missing:number,failed:number}} 累计计数(快照)
*/
_noteDeleteResult(r, rel, label = 'removePublicFile') {
if (!this.delStats) {
this.delStats = { removed: 0, skipped: 0, missing: 0, failed: 0, lastErrorPath: null };
}
const s = this.delStats;
if (r === 'removed') s.removed += 1;
else if (r === 'skipped') { s.skipped += 1; s.failed += 1; s.lastErrorPath = rel; }
else if (r === 'missing') s.missing += 1;
if (s.failed > 0) {
const logger = (this.ctx && this.ctx.logger) || (this.app && this.app.logger);
if (logger && logger.warn) {
logger.warn(
`[delStats] 累计删除异常 ${s.failed} 次` +
`(removed=${s.removed} skipped=${s.skipped} missing=${s.missing})` +
` 最近失败路径=${s.lastErrorPath} ⇒ 疑似残留孤儿文件,` +
` 可用 scan_orphan_files.py 离线复核`
);
}
}
return { ...s };
}
/** 读取累计删除统计(健康检查 / 探针 / 门禁用;⛔ 不含任何副作用)。 */
getDeleteStats() {
return { ...(this.delStats || { removed: 0, skipped: 0, missing: 0, failed: 0, lastErrorPath: null }) };
}
removePublicFile(relativePath, label = 'removePublicFile') {
const { ctx, app } = this;
const logger = (ctx && ctx.logger) || app.logger;
try {
if (!relativePath || typeof relativePath !== 'string') return 'missing';
const norm = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
if (!/^(works|tmp)\//.test(norm) || norm.includes('..')) {
logger.warn(`[removePublicFile:${label}] 拒绝删除非作品区路径(安全闸门): ${relativePath}`);
return 'skipped';
}
const absPath = path.join(app.baseDir, 'app/public', norm);
return ctx.helper.safeCleanup(absPath, { label });
} catch (e) {
logger.warn(`[removePublicFile:${label}] 异常(已忽略,不影响主流程): ${relativePath} -> ${e && e.message}`);
return 'skipped';
}
}
}
module.exports = UploadService;