369 lines
13 KiB
JavaScript
369 lines
13 KiB
JavaScript
|
|
'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;
|