Files
competition-website/app/service/upload.js
WorkBuddy a303cf2d53 chore: 后端首次基线提交
运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。
已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
2026-08-17 12:25:42 +08:00

369 lines
13 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');
/**
* 上传服务 - 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(),
};
}
/**
* 保存作品文件
* @param {ReadableStream} stream - 文件流
* @param {string} filename - 原始文件名
* @param {string} group - 组别
* @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);
});
// 🔴 落盘完整性校验(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} 字节`);
}
// 计算文件 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,
};
}
/**
* 合并文件分片
* @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) {
await existing.update({
...payload,
score: null, // 重新上传后清除评分
});
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, app } = 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)
if (record.file_path) {
const absPath = path.join(app.baseDir, 'app/public', record.file_path);
try {
if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
} catch (e) {
ctx.logger.error('[deleteSubmission] 删除物理文件失败:', e.message);
}
}
await record.destroy();
return true;
}
}
module.exports = UploadService;