426 lines
9.8 KiB
JavaScript
426 lines
9.8 KiB
JavaScript
const mongoose = require('mongoose');
|
|
|
|
const conversionTaskSchema = new mongoose.Schema({
|
|
taskId: {
|
|
type: String,
|
|
required: true,
|
|
unique: true,
|
|
index: true
|
|
},
|
|
batchId: {
|
|
type: String,
|
|
index: true // 用于批量转换任务分组
|
|
},
|
|
userId: {
|
|
type: String,
|
|
required: false, // 允许匿名用户
|
|
index: true
|
|
},
|
|
fileId: {
|
|
type: String,
|
|
required: true,
|
|
index: true
|
|
},
|
|
sourceFile: {
|
|
name: String,
|
|
size: Number,
|
|
type: String
|
|
},
|
|
outputFormat: {
|
|
type: String,
|
|
required: true,
|
|
enum: ['docx', 'html', 'txt', 'png', 'jpg', 'xlsx', 'pptx']
|
|
},
|
|
options: {
|
|
// Word转换选项
|
|
preserveLayout: Boolean,
|
|
includeImages: Boolean,
|
|
imageQuality: {
|
|
type: String,
|
|
enum: ['low', 'medium', 'high']
|
|
},
|
|
ocrEnabled: Boolean,
|
|
|
|
// HTML转换选项
|
|
responsive: Boolean,
|
|
embedImages: Boolean,
|
|
cssFramework: {
|
|
type: String,
|
|
enum: ['none', 'bootstrap', 'tailwind']
|
|
},
|
|
includeMetadata: Boolean,
|
|
|
|
// 图片转换选项
|
|
resolution: {
|
|
type: Number,
|
|
min: 72,
|
|
max: 300
|
|
},
|
|
format: {
|
|
type: String,
|
|
enum: ['png', 'jpg', 'webp']
|
|
},
|
|
quality: {
|
|
type: Number,
|
|
min: 1,
|
|
max: 100
|
|
},
|
|
pageRange: {
|
|
type: String,
|
|
enum: ['all', 'first', 'custom']
|
|
},
|
|
customRange: {
|
|
start: Number,
|
|
end: Number
|
|
},
|
|
|
|
// 文本转换选项
|
|
preserveLineBreaks: Boolean,
|
|
encoding: {
|
|
type: String,
|
|
enum: ['utf8', 'gbk', 'ascii']
|
|
},
|
|
|
|
// 批量处理选项
|
|
mergeOutput: Boolean,
|
|
zipResults: Boolean,
|
|
namePattern: String
|
|
},
|
|
status: {
|
|
type: String,
|
|
enum: ['pending', 'processing', 'completed', 'failed', 'cancelled'],
|
|
default: 'pending',
|
|
index: true
|
|
},
|
|
progress: {
|
|
type: Number,
|
|
min: 0,
|
|
max: 100,
|
|
default: 0
|
|
},
|
|
priority: {
|
|
type: Number,
|
|
default: 0,
|
|
index: true // 用于优先级队列
|
|
},
|
|
resultFile: {
|
|
fileName: String,
|
|
filePath: String,
|
|
fileSize: Number,
|
|
downloadUrl: String
|
|
},
|
|
processingMetrics: {
|
|
startTime: Date,
|
|
endTime: Date,
|
|
duration: Number, // 毫秒
|
|
peakMemoryUsage: Number, // 字节
|
|
cpuTime: Number // 毫秒
|
|
},
|
|
errorInfo: {
|
|
code: String,
|
|
message: String,
|
|
stack: String,
|
|
step: String // 失败的转换步骤
|
|
},
|
|
retryCount: {
|
|
type: Number,
|
|
default: 0,
|
|
max: 3
|
|
},
|
|
queuePosition: Number,
|
|
estimatedCompletionTime: Date,
|
|
actualCompletionTime: Date,
|
|
qualityScore: {
|
|
type: Number,
|
|
min: 0,
|
|
max: 100
|
|
},
|
|
userRating: {
|
|
score: {
|
|
type: Number,
|
|
min: 1,
|
|
max: 5
|
|
},
|
|
feedback: String,
|
|
ratedAt: Date
|
|
}
|
|
}, {
|
|
timestamps: true
|
|
});
|
|
|
|
// 复合索引
|
|
conversionTaskSchema.index({ userId: 1, createdAt: -1 });
|
|
conversionTaskSchema.index({ status: 1, priority: -1, createdAt: 1 });
|
|
conversionTaskSchema.index({ batchId: 1, status: 1 });
|
|
conversionTaskSchema.index({ fileId: 1, outputFormat: 1 });
|
|
|
|
// 虚拟字段:转换时长
|
|
conversionTaskSchema.virtual('durationFormatted').get(function() {
|
|
if (!this.processingMetrics.duration) return null;
|
|
|
|
const seconds = Math.floor(this.processingMetrics.duration / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
|
} else if (minutes > 0) {
|
|
return `${minutes}m ${seconds % 60}s`;
|
|
} else {
|
|
return `${seconds}s`;
|
|
}
|
|
});
|
|
|
|
// 虚拟字段:是否可重试
|
|
conversionTaskSchema.virtual('canRetry').get(function() {
|
|
return this.status === 'failed' && this.retryCount < 3;
|
|
});
|
|
|
|
// 虚拟字段:是否可取消
|
|
conversionTaskSchema.virtual('canCancel').get(function() {
|
|
return ['pending', 'processing'].includes(this.status);
|
|
});
|
|
|
|
// 实例方法:开始处理
|
|
conversionTaskSchema.methods.startProcessing = function() {
|
|
this.status = 'processing';
|
|
this.progress = 0;
|
|
this.processingMetrics.startTime = new Date();
|
|
return this.save();
|
|
};
|
|
|
|
// 实例方法:更新进度
|
|
conversionTaskSchema.methods.updateProgress = function(progress, step) {
|
|
this.progress = Math.min(100, Math.max(0, progress));
|
|
if (step) {
|
|
this.errorInfo.step = step;
|
|
}
|
|
return this.save();
|
|
};
|
|
|
|
// 实例方法:标记完成
|
|
conversionTaskSchema.methods.markCompleted = function(resultFile) {
|
|
this.status = 'completed';
|
|
this.progress = 100;
|
|
this.resultFile = resultFile;
|
|
this.processingMetrics.endTime = new Date();
|
|
this.actualCompletionTime = new Date();
|
|
|
|
if (this.processingMetrics.startTime) {
|
|
this.processingMetrics.duration =
|
|
this.processingMetrics.endTime - this.processingMetrics.startTime;
|
|
}
|
|
|
|
return this.save();
|
|
};
|
|
|
|
// 实例方法:标记失败
|
|
conversionTaskSchema.methods.markFailed = function(error) {
|
|
this.status = 'failed';
|
|
this.errorInfo = {
|
|
code: error.code || 'UNKNOWN_ERROR',
|
|
message: error.message,
|
|
stack: error.stack,
|
|
step: error.step || 'unknown'
|
|
};
|
|
this.processingMetrics.endTime = new Date();
|
|
|
|
if (this.processingMetrics.startTime) {
|
|
this.processingMetrics.duration =
|
|
this.processingMetrics.endTime - this.processingMetrics.startTime;
|
|
}
|
|
|
|
return this.save();
|
|
};
|
|
|
|
// 实例方法:取消任务
|
|
conversionTaskSchema.methods.cancel = function() {
|
|
if (this.canCancel) {
|
|
this.status = 'cancelled';
|
|
this.processingMetrics.endTime = new Date();
|
|
return this.save();
|
|
}
|
|
throw new Error('任务无法取消');
|
|
};
|
|
|
|
// 实例方法:重试任务
|
|
conversionTaskSchema.methods.retry = function() {
|
|
if (this.canRetry) {
|
|
this.retryCount += 1;
|
|
this.status = 'pending';
|
|
this.progress = 0;
|
|
this.errorInfo = {};
|
|
this.processingMetrics = {};
|
|
return this.save();
|
|
}
|
|
throw new Error('任务无法重试');
|
|
};
|
|
|
|
// 实例方法:设置用户评分
|
|
conversionTaskSchema.methods.setUserRating = function(score, feedback) {
|
|
this.userRating = {
|
|
score,
|
|
feedback,
|
|
ratedAt: new Date()
|
|
};
|
|
return this.save();
|
|
};
|
|
|
|
// 静态方法:获取队列中的任务
|
|
conversionTaskSchema.statics.getQueuedTasks = function(limit = 10) {
|
|
return this.find({ status: 'pending' })
|
|
.sort({ priority: -1, createdAt: 1 })
|
|
.limit(limit);
|
|
};
|
|
|
|
// 静态方法:获取用户任务历史
|
|
conversionTaskSchema.statics.getUserTasks = function(userId, options = {}) {
|
|
const {
|
|
page = 1,
|
|
limit = 10,
|
|
status,
|
|
outputFormat,
|
|
sortBy = 'createdAt',
|
|
sortOrder = -1
|
|
} = options;
|
|
|
|
const query = { userId };
|
|
if (status) query.status = status;
|
|
if (outputFormat) query.outputFormat = outputFormat;
|
|
|
|
const skip = (page - 1) * limit;
|
|
const sort = { [sortBy]: sortOrder };
|
|
|
|
return this.find(query)
|
|
.sort(sort)
|
|
.skip(skip)
|
|
.limit(limit)
|
|
.populate('fileId')
|
|
.lean();
|
|
};
|
|
|
|
// 静态方法:获取转换统计
|
|
conversionTaskSchema.statics.getConversionStats = async function(timeRange = '7d') {
|
|
const cutoffDate = new Date();
|
|
|
|
switch (timeRange) {
|
|
case '1d':
|
|
cutoffDate.setDate(cutoffDate.getDate() - 1);
|
|
break;
|
|
case '7d':
|
|
cutoffDate.setDate(cutoffDate.getDate() - 7);
|
|
break;
|
|
case '30d':
|
|
cutoffDate.setDate(cutoffDate.getDate() - 30);
|
|
break;
|
|
case '90d':
|
|
cutoffDate.setDate(cutoffDate.getDate() - 90);
|
|
break;
|
|
}
|
|
|
|
const pipeline = [
|
|
{ $match: { createdAt: { $gte: cutoffDate } } },
|
|
{
|
|
$group: {
|
|
_id: '$status',
|
|
count: { $sum: 1 },
|
|
avgDuration: { $avg: '$processingMetrics.duration' },
|
|
avgRating: { $avg: '$userRating.score' }
|
|
}
|
|
}
|
|
];
|
|
|
|
const statusStats = await this.aggregate(pipeline);
|
|
|
|
const formatStats = await this.aggregate([
|
|
{ $match: { createdAt: { $gte: cutoffDate } } },
|
|
{
|
|
$group: {
|
|
_id: '$outputFormat',
|
|
count: { $sum: 1 },
|
|
successRate: {
|
|
$avg: {
|
|
$cond: [{ $eq: ['$status', 'completed'] }, 1, 0]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]);
|
|
|
|
return {
|
|
byStatus: statusStats,
|
|
byFormat: formatStats,
|
|
timeRange
|
|
};
|
|
};
|
|
|
|
// 静态方法:清理旧任务
|
|
conversionTaskSchema.statics.cleanupOldTasks = async function(daysOld = 30) {
|
|
const cutoffDate = new Date();
|
|
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
|
|
|
const oldTasks = await this.find({
|
|
createdAt: { $lt: cutoffDate },
|
|
status: { $in: ['completed', 'failed', 'cancelled'] }
|
|
});
|
|
|
|
// 删除关联的结果文件
|
|
const fs = require('fs');
|
|
let cleanedFiles = 0;
|
|
|
|
for (const task of oldTasks) {
|
|
if (task.resultFile && task.resultFile.filePath) {
|
|
try {
|
|
if (fs.existsSync(task.resultFile.filePath)) {
|
|
fs.unlinkSync(task.resultFile.filePath);
|
|
cleanedFiles++;
|
|
}
|
|
} catch (error) {
|
|
console.error(`删除结果文件失败: ${task.resultFile.filePath}`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 删除数据库记录
|
|
const deleteResult = await this.deleteMany({
|
|
createdAt: { $lt: cutoffDate },
|
|
status: { $in: ['completed', 'failed', 'cancelled'] }
|
|
});
|
|
|
|
return {
|
|
deletedTasks: deleteResult.deletedCount,
|
|
cleanedFiles
|
|
};
|
|
};
|
|
|
|
// 中间件:保存后更新用户统计
|
|
conversionTaskSchema.post('save', async function(doc) {
|
|
if (doc.userId && doc.isModified('status')) {
|
|
try {
|
|
const User = require('./User');
|
|
const updateData = {};
|
|
|
|
if (doc.status === 'completed') {
|
|
updateData['$inc'] = {
|
|
'statistics.totalConversions': 1,
|
|
'statistics.successfulConversions': 1
|
|
};
|
|
} else if (doc.status === 'failed') {
|
|
updateData['$inc'] = {
|
|
'statistics.totalConversions': 1,
|
|
'statistics.failedConversions': 1
|
|
};
|
|
}
|
|
|
|
if (Object.keys(updateData).length > 0) {
|
|
await User.findOneAndUpdate({ userId: doc.userId }, updateData);
|
|
}
|
|
} catch (error) {
|
|
console.error('更新用户统计失败:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
const ConversionTask = mongoose.model('ConversionTask', conversionTaskSchema);
|
|
|
|
module.exports = ConversionTask; |