Files

601 lines
24 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
const Controller = require('egg').Controller;
const path = require('path');
const fs = require('fs');
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
/**
* 上传控制器 - UploadController
*
* 负责作品文件上传相关功能,包括普通上传、分片上传、分片查询、
* 合并分片、提交状态查询等。适用于本科组和教师组。
*
* 主要接口:
* - POST /api/upload/:group : 上传作品文件
* - POST /api/upload/:group/chunk : 上传分片
* - POST /api/upload/:group/merge : 合并分片
* - GET /api/upload/:group/chunk-status : 查询分片状态
* - GET /api/upload/:group/status : 查询提交状态
* - GET /api/upload/:group/submissions : 查询提交历史
*/
class UploadController extends Controller {
/**
* 校验组别参数
* @param {string} group - 组别
* @returns {boolean} 是否有效
*/
_validateGroup(group) {
const validGroups = ['benke', 'jiaoshi'];
if (!validGroups.includes(group)) {
this.ctx.body = this.ctx.helper.error(400, '无效的组别,仅支持 benke / jiaoshi');
return false;
}
return true;
}
/**
* 获取用户的聚合报名信息
* @returns {Promise<Object|null>}
*/
async _getUserAggregation() {
const user = this.ctx.state.currentUser;
if (!user || !user.uid) return null;
return await this.service.upload.getUserAggregation(user.uid);
}
/**
* 上传作品文件
* POST /api/upload/:group
*
* 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 50MB
*/
async upload() {
const { ctx, service } = this;
// 🔴 配额获取标志(见方法末 finally 的说明:为何不照搬 uploadChunk 的无条件 release)
let ioAcquired = false;
fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9) R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
// 🔴 C9 根因①的孪生缺陷(2026-09-29 · R45):文件已落盘、但 DB 记录没写成 ⇒ 同样是孤儿。
// 两个标志分别表示「已落盘」「已入库」,只有「落盘但没入库」才允许回滚删除 ——
// 若已经入库还去删文件,就会删掉**正在被引用**的文件(下载 404),那是数据丢失。
let savedFileInfo = null;
let recordPersisted = false;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 🔴 文件 IO 并发护栏(2026-09-29 · R43 补齐 —— 本端点此前**零护栏**)
//
// 背景:本端点是**在用的**作品上传路径(前端 Competition.vue:477 逐文件串行调用),
// 而同文件的 uploadChunk(:156) / mergeChunks(:275) / exam.uploadWord(:503) 都调了本护栏,
// 唯独真正承载考生上传的这一条**既未调 file_io_guard、也不在 concurrency_guard
// 的 HEAVY_PATTERNS 内**(R42 发现,已同步补入中间件)。两处都补 = 闸门 + 护栏双保险。
//
// 位置:与 uploadChunk 同构 —— 在**读取请求体之前**(ctx.getFileStream() 在下方)取配额,
// 提供连接级背压:极端并发下超限即回 429,请求体不会被解析/缓冲,避免 fd/事件循环雪崩。
//
// ⚠️ 生产缓解事实(诚实标注,避免高估本改动):服务器入方向带宽仅 ≈1.25 MB/s,
// 收包速率被物理封顶、并发上传大多耗在「等字节」⇒ **当前实际风险有限**;
// 但一旦按 C3 提升入带宽,收包不再被封顶,护栏即从「保险」变为「必需」
// (故 C7 与 C3 绑定:先补护栏再升带宽)。
try {
await acquireFileIo();
ioAcquired = true;
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
// 验证截止时间
const deadlineCheck = await service.upload.validateDeadline(group);
if (!deadlineCheck.valid) {
ctx.body = ctx.helper.error(6001, deadlineCheck.message);
return;
}
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 验证组别匹配
if (aggregation.team && aggregation.team.group_name !== group) {
ctx.body = ctx.helper.error(403, '您不属于该组别');
return;
}
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
// 🔴 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();
if (!stream) {
ctx.body = ctx.helper.error(400, '未接收到文件');
return;
}
// 验证文件扩展名
const allowedExts = ['.pdf', '.doc', '.docx', '.ppt', '.pptx', '.zip'];
const ext = path.extname(stream.filename).toLowerCase();
if (!allowedExts.includes(ext)) {
ctx.body = ctx.helper.error(6002, '不支持的文件格式,仅支持 pdf/doc/docx/ppt/pptx/zip');
return;
}
// 验证文件大小:zip 最大 200MB,其余格式最大 100MB(从请求头判断)
const contentLength = parseInt(ctx.get('content-length')) || 0;
const isZip = ext === '.zip';
const maxSize = isZip ? 200 * 1024 * 1024 : 100 * 1024 * 1024;
const limitLabel = isZip ? '200MB' : '100MB';
if (contentLength > maxSize) {
ctx.body = ctx.helper.error(6003, `文件大小超过限制(最大 ${limitLabel})`);
return;
}
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
// 获取作品名称与文件类型(流式模式字段在 stream.fields 中)。
// ⚠️ 必须先取再落盘:落盘要用 file_type 判定「同名文件是否同一次提交的新版本」。
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
const fileType = (stream.fields && stream.fields.file_type) || 'word';
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
// 保存文件:落点 = 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(
aggregation.aggregation_uid,
group,
fileInfo,
workName,
fileType
);
fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9) R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
// 🔴 入库成功即置位:此后任何异常都**不得**再删这个文件(它已被 DB 引用)
recordPersisted = true;
ctx.body = ctx.helper.success({
id: record.id,
workName: record.work_name,
filePath: fileInfo.filePath,
fileUrl: ctx.helper.fileUrl(fileInfo.filePath),
fileName: fileInfo.fileName,
fileSize: fileInfo.fileSize,
md5: fileInfo.md5,
uploadTime: record.upload_time || new Date().toISOString(),
}, '文件上传成功');
} catch (err) {
fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9) R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
// 🔴 孤儿回滚(C9 · R45):文件已落盘但记录未入库 ⇒ 立即清掉这个新文件。
// 条件严格限定为 `savedFileInfo && !recordPersisted`:
// · 未落盘(校验/护栏阶段失败)→ savedFileInfo 为 null,无事可做;
// · 已入库(recordPersisted=true)→ 文件正被 DB 引用,**绝不能删**。
// 仍然 best-effort:清理失败只告警,不能把真正的错误信息顶掉(同 helper.safeCleanup 的约定)。
if (savedFileInfo && savedFileInfo.filePath && !recordPersisted) {
service.upload.removePublicFile(savedFileInfo.filePath, 'upload-rollback');
}
ctx.logger.error('文件上传失败:', err);
const msg = (err && err.message) ? err.message : '未知错误';
ctx.body = ctx.helper.error(500, msg);
} finally {
// 🔴 释放配额。为何用 `if (ioAcquired)` 而非像 uploadChunk 那样无条件 release:
// 本方法的外层 try 包住了「校验 + 取配额 + 落盘」全过程,而 429 分支是
// **acquire 失败**(配额没拿到)后 return 的 —— 若无条件释放,会把**别人的**配额
// 放掉,配额计数下溢 ⇒ 护栏静默失效(真实并发上限被虚高),是最难查的一类退化。
// ⚠️ 反之也不能省 last 这个 finally:中途任何 return 都不允许泄漏配额,
// 否则配额只减不增,若干轮后上传通道**永久 429**。
if (ioAcquired) releaseFileIo();
}
}
/**
* 上传分片
* POST /api/upload/:group/chunk
*
* Body: multipart/form-data 包含 chunk(文件分片)、chunkIndex、totalChunks、fileId、fileName
*/
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
// @no-temp-cleanup: 分片接收端,临时目录必须**保留**待 mergeChunks 合并;
// 若在此清理则分片无法续传。清理责任在 mergeChunks(upload.js:342 safeCleanup)。
async uploadChunk() {
const { ctx, app } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 🔴 文件 IO 并发护栏:先于读取请求体获取配额,提供连接级背压;
// 极端并发(无带宽限制 + 单 worker)下超出上限则返回 429,
// 由前端 uploadWithRetry 指数退避重试,避免 fd/事件循环被压垮雪崩。
try {
await acquireFileIo();
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
try {
// 获取上传的文件流(流式模式下,非文件字段需在消费流后从 ctx.request.body 读取)
const stream = await ctx.getFileStream();
if (!stream) {
ctx.body = ctx.helper.error(400, '未接收到分片数据');
return;
}
// 流式模式下,非文件字段在 stream.fields 中(非 ctx.request.body)
const { chunkIndex, totalChunks, fileId, fileName } = stream.fields || {};
if (chunkIndex === undefined || !totalChunks || !fileId || !fileName) {
ctx.body = ctx.helper.error(400, '缺少必要参数:chunkIndex, totalChunks, fileId, fileName');
return;
}
// 构建临时分片目录
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// 保存分片文件
const chunkFilePath = path.join(tempDir, `chunk_${String(chunkIndex).padStart(6, '0')}`);
const writeStream = fs.createWriteStream(chunkFilePath);
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
stream.on('error', reject);
});
ctx.body = ctx.helper.success({
received: parseInt(chunkIndex),
totalChunks: parseInt(totalChunks),
fileId,
}, `分片 ${chunkIndex} 上传成功`);
} finally {
releaseFileIo();
}
} catch (err) {
ctx.logger.error('分片上传失败:', err);
ctx.body = ctx.helper.error(500, '分片上传失败');
}
}
/**
* 合并分片
* POST /api/upload/:group/merge
*
* Body: { fileId, fileName, totalChunks, work_name }
*/
async mergeChunks() {
const { ctx, service, app } = this;
fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9) R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
let mergedRelPath = null;
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
// 合并临时文件(先写临时名再原子 rename)。失败时需清掉,否则留下 `.merge_*` 残渣。
let mergedTmpPath = null;
fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9) R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
let recordPersisted = false;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 验证截止时间
const deadlineCheck = await service.upload.validateDeadline(group);
if (!deadlineCheck.valid) {
ctx.body = ctx.helper.error(6001, deadlineCheck.message);
return;
}
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
const { fileId, fileName, totalChunks, work_name, file_type } = ctx.request.body;
if (!fileId || !fileName) {
ctx.body = ctx.helper.error(400, '缺少必要参数:fileId, fileName');
return;
}
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
// 🔴 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);
if (!fs.existsSync(tempDir)) {
ctx.body = ctx.helper.error(6004, '未找到分片文件');
return;
}
// 收集所有分片文件并按文件名排序
const chunkFiles = fs.readdirSync(tempDir)
.filter(f => f.startsWith('chunk_'))
.sort()
.map(f => path.join(tempDir, f));
const expectedCount = parseInt(totalChunks) || 0;
if (expectedCount > 0 && chunkFiles.length < expectedCount) {
ctx.body = ctx.helper.error(6005, `分片未完成,已收到 ${chunkFiles.length}/${expectedCount}`);
return;
}
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
// 构建落盘目标:小组目录 + 原始文件名(唯一命名实现 = service.prepareWorkTarget,
// 与 saveWorkFile 同源,⛔ 不得在此另写一份随机命名)。
const target = await service.upload.prepareWorkTarget({
group,
folderName: teamFolder,
filename: fileName,
fileType: file_type || 'word',
aggregationUid: aggregation.aggregation_uid,
});
// 先合并到临时名,再原子 rename 到最终名 —— 避免管理员在合并过程中下载到半截文件
mergedTmpPath = path.join(
target.dir,
`.merge_${Date.now()}_${Math.random().toString(36).substring(2, 8)}${path.extname(target.fileName)}`
);
// 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额,
// 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。
try {
await acquireFileIo();
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
try {
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
// 合并分片(落到临时名)
await service.upload.mergeFileChunks(chunkFiles, mergedTmpPath);
// 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。
// 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会
// 抛到外层 catch → 返回「分片合并失败」,而目标文件已落盘且 DB 记录还没写,
// 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。
ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' });
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
// 原子落位 + 计算 size/md5(filePath 为相对 app/public 的正斜杠路径)
const fileInfo = await service.upload.commitWorkFile(mergedTmpPath, target);
mergedRelPath = fileInfo.filePath; // 供 catch 回滚孤儿文件用
// 保存提交记录
const record = await service.upload.saveSubmissionRecord(
aggregation.aggregation_uid,
group,
fileInfo,
work_name || fileName,
file_type || 'word'
);
fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9) R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
// 入库成功即置位:此后异常不得再删该文件(已被 DB 引用)
recordPersisted = true;
ctx.body = ctx.helper.success({
id: record.id,
workName: record.work_name,
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
filePath: fileInfo.filePath,
fileUrl: ctx.helper.fileUrl(fileInfo.filePath),
fileName: fileInfo.fileName,
fileSize: fileInfo.fileSize,
md5: fileInfo.md5,
uploadTime: record.upload_time || new Date().toISOString(),
}, '分片合并成功');
} finally {
releaseFileIo();
}
} catch (err) {
fix(backend): 治理孤儿文件三根因(覆盖前删旧件 / 入库失败回滚 / 清库同步删件)(BACKLOG C9) R45:C8 只清了存量(1003 个 / 9.62 GB),「孤儿为何持续产生」的三条根因仍在,本次一并治理。 顺序铁律「先库后盘」:文件删除不可回滚,若先删盘而 DB 写/事务失败 ⇒ DB 指向已删文件 ⇒ 下载 404 = 数据丢失(比留一个孤儿严重)。故全部删除点一律「DB 侧确定成功 → 才删物理文件」。 - app/service/upload.js · saveSubmissionRecord:覆盖前记住旧物理文件路径,existing.update() 成功之后才删旧件 (原先 upsert 只改 DB 指向 ⇒ 重传 N 次 = 1 条 DB + N-1 个孤儿) · deleteSubmission:裸 fs.unlinkSync → 复用统一入口 · 新增 removePublicFile(relativePath, label):唯一删除入口。仅允许 works/ 或 tmp/ 前缀 (挡住脏 file_path 指向 templates/、carousel/ 等静态资源被连带删除)+ 拒含 `..` + best-effort 永不抛错,返回 removed|missing|skipped(复用 helper.safeCleanup) - app/controller/upload.js · upload() / mergeChunks():新增 savedFileInfo|mergedRelPath + recordPersisted 两个标志, catch 里仅当「已落盘 && 未入库」才回滚删除 ⇒ 已入库还删会删掉正被 DB 引用的文件 - app/controller/admin.js · resetTestData:事务内先只读收集六列路径(exam/work/zhongzhi/gaozhi/benke/jiaoshi), tx.commit() 之后才删物理文件 + 去重 + 上报 orphanFileCandidates/orphanFilesRemoved 验证:verify-c9.mjs 24/24 · verify-c9-reset.mjs 12/12 · verify-c9-rollback.mjs 13/13 check_upload_import_gate.py 14 PASS/0 FAIL · 后端重启(node dev.js)后真实接口联调通过 配套:scripts/scan_orphan_files.py(兜底双向扫描,仓库级工具)
2026-09-29 15:06:53 +08:00
// 🔴 孤儿回滚(C9 · R45):合并结果已落盘但记录未入库 ⇒ 清掉该文件。语义同 upload()。
if (mergedRelPath && !recordPersisted) {
service.upload.removePublicFile(mergedRelPath, 'merge-rollback');
}
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
// 合并中途失败 ⇒ 临时名文件也必须清掉(best-effort,绝不掩盖真实错误)
if (mergedTmpPath && !mergedRelPath) {
ctx.helper.safeCleanup(mergedTmpPath, { label: 'mergeChunks-failed' });
}
ctx.logger.error('分片合并失败:', err);
ctx.body = ctx.helper.error(500, '分片合并失败');
}
}
/**
* 查询分片上传进度
* GET /api/upload/:group/chunk-status?fileId=xxx
*/
feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复 成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00
// @no-temp-cleanup: 本方法**只读探测**临时目录(fs.existsSync + readdirSync),
// 不创建也不删除任何文件 ⇒ 「作用域内无清理」是正确形态,不是缺口。
async chunkStatus() {
const { ctx, app } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const { fileId } = ctx.query;
if (!fileId) {
ctx.body = ctx.helper.error(400, '缺少 fileId 参数');
return;
}
// 检查临时目录
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
if (!fs.existsSync(tempDir)) {
ctx.body = ctx.helper.success({
received: 0,
chunks: [],
}, '尚未收到分片');
return;
}
const chunkFiles = fs.readdirSync(tempDir)
.filter(f => f.startsWith('chunk_'))
.sort();
ctx.body = ctx.helper.success({
received: chunkFiles.length,
chunks: chunkFiles.map(f => f.replace('chunk_', '')),
}, '获取分片状态成功');
} catch (err) {
ctx.logger.error('查询分片状态失败:', err);
ctx.body = ctx.helper.error(500, '查询分片状态失败');
}
}
/**
* 查询当前用户的提交状态
* GET /api/upload/:group/status
*/
async status() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
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
// 检查提交状态(本人)+ 组内互斥状态(R190)
const [submitted, teamState, deadlineCheck] = await Promise.all([
service.upload.hasSubmitted(aggregation.aggregation_uid, group),
service.upload.getTeamUploadState(aggregation, group),
service.upload.validateDeadline(group),
]);
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
// 🔴 组锁状态必须暴露给前端,否则被锁成员会看到「未上传」却任何操作都失败,
// 完全不知道原因(同一类「状态不可见」缺陷在考试组锁上已踩过)。
const canUpload = deadlineCheck.valid && !teamState.locked;
ctx.body = ctx.helper.success({
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
submitted, // 本人是否已上传
deadline: deadlineCheck.deadline,
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
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) {
ctx.logger.error('查询提交状态失败:', err);
ctx.body = ctx.helper.error(500, '查询提交状态失败');
}
}
/**
* 删除已上传的作品文件
* DELETE /api/upload/:group/:id
*
* 仅允许删除当前登录用户本人的提交记录,并同步删除物理文件。
*/
async delete() {
const { ctx, service } = this;
try {
const { group, id } = ctx.params;
if (!this._validateGroup(group)) return;
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
const numId = parseInt(id, 10);
if (!Number.isInteger(numId)) {
ctx.body = ctx.helper.error(400, '无效的文件 ID');
return;
}
const ok = await service.upload.deleteSubmission(aggregation.aggregation_uid, group, numId);
if (!ok) {
ctx.body = ctx.helper.error(404, '未找到该文件或无权删除');
return;
}
ctx.body = ctx.helper.success({ id: numId }, '文件已删除');
} catch (err) {
ctx.logger.error('删除提交文件失败:', err);
ctx.body = ctx.helper.error(500, '删除失败');
}
}
/**
* 查询当前用户的提交历史
* GET /api/upload/:group/submissions
*/
async submissions() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 获取提交历史
const submissionList = await service.upload.getSubmissions(
aggregation.aggregation_uid,
group
);
ctx.body = ctx.helper.success({
list: submissionList,
total: submissionList.length,
}, '获取提交历史成功');
} catch (err) {
ctx.logger.error('查询提交历史失败:', err);
ctx.body = ctx.helper.error(500, '查询提交历史失败');
}
}
}
module.exports = UploadController;