feat: Initial commit of PDF Tools project
This commit is contained in:
426
server/models/ConversionTask.js
Normal file
426
server/models/ConversionTask.js
Normal file
@@ -0,0 +1,426 @@
|
||||
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;
|
||||
307
server/models/File.js
Normal file
307
server/models/File.js
Normal file
@@ -0,0 +1,307 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const fileSchema = new mongoose.Schema({
|
||||
fileId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
userId: {
|
||||
type: String,
|
||||
required: false, // 允许匿名用户上传
|
||||
index: true
|
||||
},
|
||||
originalName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
fileName: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
fileSize: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
mimeType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/html',
|
||||
'text/plain',
|
||||
'image/png',
|
||||
'image/jpeg'
|
||||
]
|
||||
},
|
||||
filePath: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['uploading', 'ready', 'processing', 'completed', 'failed', 'deleted'],
|
||||
default: 'ready'
|
||||
},
|
||||
metadata: {
|
||||
pageCount: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
hasImages: Boolean,
|
||||
hasText: Boolean,
|
||||
isEncrypted: Boolean,
|
||||
pdfVersion: String,
|
||||
creationDate: Date,
|
||||
modificationDate: Date,
|
||||
author: String,
|
||||
title: String,
|
||||
subject: String,
|
||||
keywords: String
|
||||
},
|
||||
checksum: {
|
||||
type: String,
|
||||
index: true // 用于去重
|
||||
},
|
||||
downloadCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
lastDownloaded: Date,
|
||||
tags: [String],
|
||||
isPublic: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
expiresAt: {
|
||||
type: Date,
|
||||
index: { expireAfterSeconds: 0 } // MongoDB TTL索引,自动删除过期文件
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// 复合索引
|
||||
fileSchema.index({ userId: 1, createdAt: -1 });
|
||||
fileSchema.index({ status: 1, createdAt: -1 });
|
||||
fileSchema.index({ mimeType: 1, status: 1 });
|
||||
fileSchema.index({ fileSize: 1 });
|
||||
|
||||
// 虚拟字段:格式化文件大小
|
||||
fileSchema.virtual('formattedFileSize').get(function() {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (this.fileSize === 0) return '0 Bytes';
|
||||
|
||||
const i = Math.floor(Math.log(this.fileSize) / Math.log(1024));
|
||||
const size = (this.fileSize / Math.pow(1024, i)).toFixed(1);
|
||||
return size + ' ' + sizes[i];
|
||||
});
|
||||
|
||||
// 虚拟字段:文件扩展名
|
||||
fileSchema.virtual('fileExtension').get(function() {
|
||||
const mimeToExt = {
|
||||
'application/pdf': 'pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
'text/html': 'html',
|
||||
'text/plain': 'txt',
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg'
|
||||
};
|
||||
return mimeToExt[this.mimeType] || 'unknown';
|
||||
});
|
||||
|
||||
// 虚拟字段:是否已过期
|
||||
fileSchema.virtual('isExpired').get(function() {
|
||||
if (!this.expiresAt) return false;
|
||||
return new Date() > this.expiresAt;
|
||||
});
|
||||
|
||||
// 实例方法:设置过期时间
|
||||
fileSchema.methods.setExpiration = function(hours = 24) {
|
||||
this.expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// 实例方法:增加下载次数
|
||||
fileSchema.methods.incrementDownload = function() {
|
||||
this.downloadCount += 1;
|
||||
this.lastDownloaded = new Date();
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// 实例方法:更新状态
|
||||
fileSchema.methods.updateStatus = function(status) {
|
||||
this.status = status;
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// 静态方法:根据校验和查找重复文件
|
||||
fileSchema.statics.findByChecksum = function(checksum) {
|
||||
return this.findOne({ checksum, status: { $ne: 'deleted' } });
|
||||
};
|
||||
|
||||
// 静态方法:获取用户文件
|
||||
fileSchema.statics.getUserFiles = function(userId, options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status,
|
||||
mimeType,
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = -1
|
||||
} = options;
|
||||
|
||||
const query = { userId };
|
||||
|
||||
if (status) query.status = status;
|
||||
if (mimeType) query.mimeType = mimeType;
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
const sort = { [sortBy]: sortOrder };
|
||||
|
||||
return this.find(query)
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.lean();
|
||||
};
|
||||
|
||||
// 静态方法:清理过期文件
|
||||
fileSchema.statics.cleanupExpiredFiles = async function() {
|
||||
const expiredFiles = await this.find({
|
||||
expiresAt: { $lt: new Date() },
|
||||
status: { $ne: 'deleted' }
|
||||
});
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
let cleanedCount = 0;
|
||||
let freedSpace = 0;
|
||||
|
||||
for (const file of expiredFiles) {
|
||||
try {
|
||||
// 删除物理文件
|
||||
if (fs.existsSync(file.filePath)) {
|
||||
freedSpace += file.fileSize;
|
||||
fs.unlinkSync(file.filePath);
|
||||
}
|
||||
|
||||
// 更新数据库记录
|
||||
await this.findByIdAndUpdate(file._id, { status: 'deleted' });
|
||||
cleanedCount++;
|
||||
} catch (error) {
|
||||
console.error(`清理文件失败: ${file.fileName}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { cleanedCount, freedSpace };
|
||||
};
|
||||
|
||||
// 静态方法:获取文件统计
|
||||
fileSchema.statics.getFileStats = async function() {
|
||||
const pipeline = [
|
||||
{
|
||||
$group: {
|
||||
_id: '$status',
|
||||
count: { $sum: 1 },
|
||||
totalSize: { $sum: '$fileSize' }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const statusStats = await this.aggregate(pipeline);
|
||||
|
||||
const typeStats = await this.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$mimeType',
|
||||
count: { $sum: 1 },
|
||||
totalSize: { $sum: '$fileSize' }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const totalStats = await this.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalFiles: { $sum: 1 },
|
||||
totalSize: { $sum: '$fileSize' },
|
||||
totalDownloads: { $sum: '$downloadCount' }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
return {
|
||||
byStatus: statusStats,
|
||||
byType: typeStats,
|
||||
total: totalStats[0] || { totalFiles: 0, totalSize: 0, totalDownloads: 0 }
|
||||
};
|
||||
};
|
||||
|
||||
// 静态方法:搜索文件
|
||||
fileSchema.statics.searchFiles = function(searchTerm, options = {}) {
|
||||
const {
|
||||
userId,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status = { $ne: 'deleted' }
|
||||
} = options;
|
||||
|
||||
const query = {
|
||||
status,
|
||||
$or: [
|
||||
{ originalName: { $regex: searchTerm, $options: 'i' } },
|
||||
{ tags: { $in: [new RegExp(searchTerm, 'i')] } },
|
||||
{ 'metadata.title': { $regex: searchTerm, $options: 'i' } },
|
||||
{ 'metadata.subject': { $regex: searchTerm, $options: 'i' } }
|
||||
]
|
||||
};
|
||||
|
||||
if (userId) query.userId = userId;
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
return this.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.lean();
|
||||
};
|
||||
|
||||
// 中间件:删除前清理物理文件
|
||||
fileSchema.pre('remove', async function(next) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
fs.unlinkSync(this.filePath);
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('删除物理文件失败:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// 中间件:保存后更新用户统计
|
||||
fileSchema.post('save', async function(doc) {
|
||||
if (doc.userId && doc.isNew) {
|
||||
try {
|
||||
const User = require('./User');
|
||||
await User.findOneAndUpdate(
|
||||
{ userId: doc.userId },
|
||||
{ $inc: { 'statistics.totalFileSize': doc.fileSize } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('更新用户统计失败:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const File = mongoose.model('File', fileSchema);
|
||||
|
||||
module.exports = File;
|
||||
249
server/models/User.js
Normal file
249
server/models/User.js
Normal file
@@ -0,0 +1,249 @@
|
||||
const mongoose = require('mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const userSchema = new mongoose.Schema({
|
||||
userId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true,
|
||||
trim: true,
|
||||
match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, '请输入有效的邮箱地址']
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
minlength: 2,
|
||||
maxlength: 50
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 6
|
||||
},
|
||||
settings: {
|
||||
defaultOutputFormat: {
|
||||
type: String,
|
||||
enum: ['docx', 'html', 'txt', 'png', 'jpg'],
|
||||
default: 'docx'
|
||||
},
|
||||
imageQuality: {
|
||||
type: String,
|
||||
enum: ['low', 'medium', 'high'],
|
||||
default: 'medium'
|
||||
},
|
||||
autoDownload: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
enum: ['zh-CN', 'en-US'],
|
||||
default: 'zh-CN'
|
||||
},
|
||||
autoDelete: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
deleteDelay: {
|
||||
type: Number,
|
||||
default: 24, // 小时
|
||||
min: 1,
|
||||
max: 168 // 7天
|
||||
},
|
||||
maxConcurrentTasks: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
min: 1,
|
||||
max: 10
|
||||
},
|
||||
conversionTimeout: {
|
||||
type: Number,
|
||||
default: 10, // 分钟
|
||||
min: 5,
|
||||
max: 60
|
||||
}
|
||||
},
|
||||
profile: {
|
||||
avatar: String,
|
||||
bio: String,
|
||||
company: String,
|
||||
website: String
|
||||
},
|
||||
statistics: {
|
||||
totalConversions: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
successfulConversions: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
failedConversions: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
totalFileSize: {
|
||||
type: Number,
|
||||
default: 0 // 字节
|
||||
}
|
||||
},
|
||||
lastLoginAt: {
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
lastActiveAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isEmailVerified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
emailVerificationToken: String,
|
||||
passwordResetToken: String,
|
||||
passwordResetExpires: Date
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: {
|
||||
transform: function(doc, ret) {
|
||||
delete ret.password;
|
||||
delete ret.emailVerificationToken;
|
||||
delete ret.passwordResetToken;
|
||||
delete ret.passwordResetExpires;
|
||||
delete ret.__v;
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 索引
|
||||
userSchema.index({ email: 1 });
|
||||
userSchema.index({ username: 1 });
|
||||
userSchema.index({ createdAt: -1 });
|
||||
userSchema.index({ lastActiveAt: -1 });
|
||||
|
||||
// 密码加密中间件
|
||||
userSchema.pre('save', async function(next) {
|
||||
if (!this.isModified('password')) return next();
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(12);
|
||||
this.password = await bcrypt.hash(this.password, salt);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// 实例方法:验证密码
|
||||
userSchema.methods.comparePassword = async function(candidatePassword) {
|
||||
try {
|
||||
return await bcrypt.compare(candidatePassword, this.password);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 实例方法:更新最后活动时间
|
||||
userSchema.methods.updateLastActive = function() {
|
||||
this.lastActiveAt = new Date();
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// 实例方法:更新统计信息
|
||||
userSchema.methods.updateStatistics = function(update) {
|
||||
Object.assign(this.statistics, update);
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// 静态方法:根据邮箱查找用户
|
||||
userSchema.statics.findByEmail = function(email) {
|
||||
return this.findOne({ email: email.toLowerCase() });
|
||||
};
|
||||
|
||||
// 静态方法:获取活跃用户
|
||||
userSchema.statics.getActiveUsers = function(days = 30) {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||
|
||||
return this.find({
|
||||
lastActiveAt: { $gte: cutoffDate },
|
||||
isActive: true
|
||||
});
|
||||
};
|
||||
|
||||
// 静态方法:获取用户统计
|
||||
userSchema.statics.getUserStats = async function() {
|
||||
const pipeline = [
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalUsers: { $sum: 1 },
|
||||
activeUsers: {
|
||||
$sum: {
|
||||
$cond: [{ $eq: ['$isActive', true] }, 1, 0]
|
||||
}
|
||||
},
|
||||
verifiedUsers: {
|
||||
$sum: {
|
||||
$cond: [{ $eq: ['$isEmailVerified', true] }, 1, 0]
|
||||
}
|
||||
},
|
||||
totalConversions: { $sum: '$statistics.totalConversions' },
|
||||
totalFileSize: { $sum: '$statistics.totalFileSize' }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const result = await this.aggregate(pipeline);
|
||||
return result[0] || {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
verifiedUsers: 0,
|
||||
totalConversions: 0,
|
||||
totalFileSize: 0
|
||||
};
|
||||
};
|
||||
|
||||
// 虚拟字段:转换成功率
|
||||
userSchema.virtual('conversionSuccessRate').get(function() {
|
||||
if (this.statistics.totalConversions === 0) return 0;
|
||||
return (this.statistics.successfulConversions / this.statistics.totalConversions * 100).toFixed(1);
|
||||
});
|
||||
|
||||
// 虚拟字段:格式化文件大小
|
||||
userSchema.virtual('formattedFileSize').get(function() {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
if (this.statistics.totalFileSize === 0) return '0 Bytes';
|
||||
|
||||
const i = Math.floor(Math.log(this.statistics.totalFileSize) / Math.log(1024));
|
||||
return (this.statistics.totalFileSize / Math.pow(1024, i)).toFixed(1) + ' ' + sizes[i];
|
||||
});
|
||||
|
||||
// 中间件:删除前清理关联数据
|
||||
userSchema.pre('remove', async function(next) {
|
||||
try {
|
||||
// 这里可以添加删除用户相关文件和转换记录的逻辑
|
||||
// await FileModel.deleteMany({ userId: this.userId });
|
||||
// await ConversionTaskModel.deleteMany({ userId: this.userId });
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
const User = mongoose.model('User', userSchema);
|
||||
|
||||
module.exports = User;
|
||||
Reference in New Issue
Block a user