feat: Initial commit of PDF Tools project

This commit is contained in:
2025-08-25 02:29:48 +08:00
parent af6827cd9e
commit 30180e50a2
48 changed files with 36364 additions and 1 deletions

39
server/.env.example Normal file
View File

@@ -0,0 +1,39 @@
# 服务器配置
PORT=3001
NODE_ENV=development
# 客户端URL
CLIENT_URL=http://localhost:3000
# 数据库配置
MONGODB_URI=mongodb://localhost:27017/pdf-tools
REDIS_URL=redis://localhost:6379
# JWT密钥
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=24h
# 文件存储配置
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=50MB
ALLOWED_FILE_TYPES=pdf,doc,docx,txt,html
# 转换引擎配置
CONVERSION_TIMEOUT=300000
MAX_CONCURRENT_CONVERSIONS=5
# 缓存配置
CACHE_TTL=3600
REDIS_PREFIX=pdf-tools:
# 邮件配置(可选)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
# 系统配置
ENABLE_ANALYTICS=false
ENABLE_RATE_LIMITING=true
RATE_LIMIT_WINDOW=900000
RATE_LIMIT_MAX=100

203
server/config/database.js Normal file
View File

@@ -0,0 +1,203 @@
const mongoose = require('mongoose');
const redis = require('redis');
class DatabaseConnection {
constructor() {
this.mongooseConnection = null;
this.redisClient = null;
}
// 连接MongoDB
async connectMongoDB() {
try {
const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/pdf-tools';
this.mongooseConnection = await mongoose.connect(mongoUri, {
useNewUrlParser: true,
useUnifiedTopology: true,
serverSelectionTimeoutMS: 5000,
maxPoolSize: 10,
socketTimeoutMS: 45000,
});
console.log('✅ MongoDB连接成功');
// 监听连接事件
mongoose.connection.on('error', (err) => {
console.error('❌ MongoDB连接错误:', err);
});
mongoose.connection.on('disconnected', () => {
console.log('⚠️ MongoDB连接断开');
});
mongoose.connection.on('reconnected', () => {
console.log('✅ MongoDB重新连接成功');
});
return this.mongooseConnection;
} catch (error) {
console.error('❌ MongoDB连接失败:', error);
throw error;
}
}
// 连接Redis
async connectRedis() {
try {
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
this.redisClient = redis.createClient({
url: redisUrl,
retry_strategy: (options) => {
if (options.error && options.error.code === 'ECONNREFUSED') {
console.error('❌ Redis服务器拒绝连接');
return new Error('Redis服务器拒绝连接');
}
if (options.total_retry_time > 1000 * 60 * 60) {
console.error('❌ Redis重连超时');
return new Error('Redis重连超时');
}
if (options.attempt > 10) {
console.error('❌ Redis重连次数超限');
return undefined;
}
// 重连间隔递增
return Math.min(options.attempt * 100, 3000);
}
});
this.redisClient.on('error', (err) => {
console.error('❌ Redis连接错误:', err);
});
this.redisClient.on('connect', () => {
console.log('✅ Redis连接成功');
});
this.redisClient.on('reconnecting', () => {
console.log('🔄 Redis重新连接中...');
});
this.redisClient.on('ready', () => {
console.log('✅ Redis准备就绪');
});
await this.redisClient.connect();
return this.redisClient;
} catch (error) {
console.error('❌ Redis连接失败:', error);
// Redis连接失败不应该阻止应用启动
return null;
}
}
// 初始化所有数据库连接
async initialize() {
try {
// 并行连接数据库
const [mongoConnection, redisConnection] = await Promise.allSettled([
this.connectMongoDB(),
this.connectRedis()
]);
if (mongoConnection.status === 'rejected') {
throw new Error(`MongoDB连接失败: ${mongoConnection.reason.message}`);
}
if (redisConnection.status === 'rejected') {
console.warn('⚠️ Redis连接失败将使用内存缓存');
}
console.log('🎉 数据库初始化完成');
return {
mongodb: mongoConnection.value,
redis: redisConnection.status === 'fulfilled' ? redisConnection.value : null
};
} catch (error) {
console.error('❌ 数据库初始化失败:', error);
throw error;
}
}
// 关闭所有连接
async close() {
try {
const promises = [];
if (this.mongooseConnection) {
promises.push(mongoose.connection.close());
}
if (this.redisClient) {
promises.push(this.redisClient.quit());
}
await Promise.all(promises);
console.log('✅ 数据库连接已关闭');
} catch (error) {
console.error('❌ 关闭数据库连接失败:', error);
}
}
// 获取MongoDB连接状态
getMongoStatus() {
return {
status: mongoose.connection.readyState,
host: mongoose.connection.host,
port: mongoose.connection.port,
name: mongoose.connection.name
};
}
// 获取Redis连接状态
getRedisStatus() {
if (!this.redisClient) {
return { status: 'disconnected', message: '未连接' };
}
return {
status: this.redisClient.isReady ? 'connected' : 'disconnected',
message: this.redisClient.isReady ? '已连接' : '未连接'
};
}
// 健康检查
async healthCheck() {
const health = {
mongodb: { status: 'unknown', message: '' },
redis: { status: 'unknown', message: '' }
};
try {
// MongoDB健康检查
if (mongoose.connection.readyState === 1) {
await mongoose.connection.db.admin().ping();
health.mongodb = { status: 'healthy', message: '连接正常' };
} else {
health.mongodb = { status: 'unhealthy', message: '连接异常' };
}
} catch (error) {
health.mongodb = { status: 'unhealthy', message: error.message };
}
try {
// Redis健康检查
if (this.redisClient && this.redisClient.isReady) {
await this.redisClient.ping();
health.redis = { status: 'healthy', message: '连接正常' };
} else {
health.redis = { status: 'unhealthy', message: '连接异常' };
}
} catch (error) {
health.redis = { status: 'unhealthy', message: error.message };
}
return health;
}
}
// 创建单例实例
const databaseConnection = new DatabaseConnection();
module.exports = databaseConnection;

35
server/healthcheck.js Normal file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env node
const http = require('http');
const process = require('process');
const options = {
hostname: 'localhost',
port: process.env.PORT || 3001,
path: '/health',
method: 'GET',
timeout: 3000,
};
const healthCheck = http.request(options, (res) => {
console.log(`Health check status: ${res.statusCode}`);
if (res.statusCode === 200) {
process.exit(0);
} else {
process.exit(1);
}
});
healthCheck.on('error', (err) => {
console.error('Health check failed:', err.message);
process.exit(1);
});
healthCheck.on('timeout', () => {
console.error('Health check timeout');
healthCheck.destroy();
process.exit(1);
});
healthCheck.end();

82
server/index.js Normal file
View File

@@ -0,0 +1,82 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const path = require('path');
const dotenv = require('dotenv');
// 导入路由
const fileRoutes = require('./routes/files');
const conversionRoutes = require('./routes/conversion');
const userRoutes = require('./routes/users');
const systemRoutes = require('./routes/system');
// 导入中间件
const errorHandler = require('./middleware/errorHandler');
const authMiddleware = require('./middleware/auth');
// 加载环境变量
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
// 安全中间件
app.use(helmet());
// CORS配置
app.use(cors({
origin: process.env.CLIENT_URL || 'http://localhost:3000',
credentials: true
}));
// 请求限流
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 100, // 每个IP最多100个请求
message: '请求过于频繁,请稍后再试'
});
app.use('/api/', limiter);
// 解析请求体
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// 静态文件服务
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// API路由
app.use('/api/files', fileRoutes);
app.use('/api/convert', conversionRoutes);
app.use('/api/users', userRoutes);
app.use('/api/system', systemRoutes);
// 健康检查端点
app.get('/health', (req, res) => {
res.json({
status: 'OK',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage()
});
});
// 错误处理中间件
app.use(errorHandler);
// 404处理
app.use('*', (req, res) => {
res.status(404).json({
success: false,
message: '接口不存在',
timestamp: new Date().toISOString()
});
});
// 启动服务器
app.listen(PORT, () => {
console.log(`🚀 PDF转换工具服务器运行在端口 ${PORT}`);
console.log(`📊 健康检查: http://localhost:${PORT}/health`);
});
module.exports = app;

42
server/middleware/auth.js Normal file
View File

@@ -0,0 +1,42 @@
const jwt = require('jsonwebtoken');
const auth = (req, res, next) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
success: false,
message: '未提供访问令牌'
});
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({
success: false,
message: '无效的访问令牌'
});
}
};
// 可选身份验证中间件
const optionalAuth = (req, res, next) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (token) {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
}
next();
} catch (error) {
// 忽略错误,继续执行
next();
}
};
module.exports = { auth, optionalAuth };

View File

@@ -0,0 +1,52 @@
const errorHandler = (err, req, res, next) => {
console.error('错误详情:', err);
// 默认错误信息
let error = { ...err };
error.message = err.message;
// MongoDB错误处理
if (err.name === 'CastError') {
const message = '资源未找到';
error = { message, statusCode: 404 };
}
// MongoDB重复键错误
if (err.code === 11000) {
const message = '资源已存在';
error = { message, statusCode: 400 };
}
// MongoDB验证错误
if (err.name === 'ValidationError') {
const message = Object.values(err.errors).map(val => val.message).join(', ');
error = { message, statusCode: 400 };
}
// 文件上传错误
if (err.code === 'LIMIT_FILE_SIZE') {
const message = '文件大小超出限制';
error = { message, statusCode: 400 };
}
// JWT错误
if (err.name === 'JsonWebTokenError') {
const message = '无效的访问令牌';
error = { message, statusCode: 401 };
}
// JWT过期错误
if (err.name === 'TokenExpiredError') {
const message = '访问令牌已过期';
error = { message, statusCode: 401 };
}
res.status(error.statusCode || 500).json({
success: false,
message: error.message || '服务器内部错误',
timestamp: new Date().toISOString(),
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
};
module.exports = errorHandler;

View 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
View 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
View 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;

6941
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
server/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "pdf-tools-server",
"version": "1.0.0",
"description": "PDF转换工具后端服务",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"multer": "^1.4.5-lts.1",
"pdf-lib": "^1.17.1",
"pdf2pic": "^2.1.4",
"pdf-parse": "^1.1.1",
"puppeteer": "^21.5.2",
"mammoth": "^1.6.0",
"xlsx": "^0.18.5",
"archiver": "^6.0.1",
"mongoose": "^8.0.3",
"redis": "^4.6.10",
"jsonwebtoken": "^9.0.2",
"bcryptjs": "^2.4.3",
"dotenv": "^16.3.1",
"helmet": "^7.1.0",
"express-rate-limit": "^7.1.5",
"uuid": "^9.0.1",
"jimp": "^0.22.10"
},
"devDependencies": {
"nodemon": "^3.0.2",
"jest": "^29.7.0",
"supertest": "^6.3.3"
}
}

200
server/routes/conversion.js Normal file
View File

@@ -0,0 +1,200 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const { optionalAuth } = require('../middleware/auth');
const conversionService = require('../services/conversionService');
const ConversionTask = require('../models/ConversionTask');
const File = require('../models/File');
const router = express.Router();
/**
* @route POST /api/convert/start
* @desc 开始一个新的文件转换任务
* @access Private (optional)
*/
router.post('/start', optionalAuth, async (req, res, next) => {
try {
const { fileId, outputFormat, options = {} } = req.body;
if (!fileId || !outputFormat) {
return res.status(400).json({ success: false, message: '缺少必要参数fileId和outputFormat' });
}
// 验证文件是否存在
const file = await File.findOne({ fileId });
if (!file) {
return res.status(404).json({ success: false, message: '文件未找到' });
}
// 创建转换任务
const task = await ConversionTask.create({
taskId: uuidv4(),
fileId,
outputFormat,
options,
userId: req.user?.userId || null,
sourceFile: {
name: file.fileName,
size: file.size,
type: file.mimeType
}
});
// 异步开始转换,不阻塞响应
conversionService.startConversion(task.taskId);
res.status(202).json({
success: true,
message: '转换任务已创建',
data: {
taskId: task.taskId,
status: task.status,
progress: task.progress
}
});
} catch (error) {
next(error);
}
});
/**
* @route GET /api/convert/status/:taskId
* @desc 查询转换任务的状态
* @access Public
*/
router.get('/status/:taskId', async (req, res, next) => {
try {
const { taskId } = req.params;
const task = await ConversionTask.findOne({ taskId }).lean();
if (!task) {
return res.status(404).json({ success: false, message: '转换任务未找到' });
}
res.json({ success: true, data: task });
} catch (error) {
next(error);
}
});
/**
* @route GET /api/convert/result/:taskId
* @desc 获取转换任务的结果
* @access Public
*/
router.get('/result/:taskId', async (req, res, next) => {
try {
const { taskId } = req.params;
const task = await ConversionTask.findOne({ taskId }).lean();
if (!task) {
return res.status(404).json({ success: false, message: '转换任务未找到' });
}
if (task.status !== 'completed') {
return res.status(400).json({ success: false, message: '转换尚未完成' });
}
res.json({
success: true,
data: {
taskId: task.taskId,
resultUrl: task.resultFile.downloadUrl,
fileName: task.resultFile.fileName,
fileSize: task.resultFile.fileSize
}
});
} catch (error) {
next(error);
}
});
/**
* @route POST /api/convert/batch
* @desc 开始批量转换任务
* @access Private (optional)
*/
router.post('/batch', optionalAuth, async (req, res, next) => {
try {
const { fileIds, outputFormat, options = {} } = req.body;
if (!fileIds || !Array.isArray(fileIds) || fileIds.length === 0) {
return res.status(400).json({ success: false, message: '请提供要转换的文件ID列表' });
}
if (fileIds.length > 10) {
return res.status(400).json({ success: false, message: '批量转换最多支持10个文件' });
}
const batchId = uuidv4();
const createdTasks = [];
for (const fileId of fileIds) {
const file = await File.findOne({ fileId });
if (file) {
const task = await ConversionTask.create({
taskId: uuidv4(),
batchId,
fileId,
outputFormat,
options,
userId: req.user?.userId || null,
sourceFile: {
name: file.fileName,
size: file.size,
type: file.mimeType
}
});
createdTasks.push(task);
// 异步开始转换
conversionService.startConversion(task.taskId);
}
}
res.status(202).json({
success: true,
message: '批量转换任务已创建',
data: {
batchId,
taskCount: createdTasks.length,
tasks: createdTasks.map(t => ({ taskId: t.taskId, status: t.status }))
}
});
} catch (error) {
next(error);
}
});
/**
* @route POST /api/convert/cancel/:taskId
* @desc 取消一个正在进行的转换任务
* @access Private (optional)
*/
router.post('/cancel/:taskId', optionalAuth, async (req, res, next) => {
try {
const { taskId } = req.params;
const task = await ConversionTask.findOne({ taskId });
if (!task) {
return res.status(404).json({ success: false, message: '转换任务未找到' });
}
// 权限检查:确保用户只能取消自己的任务
if (task.userId && (!req.user || task.userId !== req.user.userId)) {
return res.status(403).json({ success: false, message: '无权操作此任务' });
}
await task.cancel();
res.json({ success: true, message: '转换任务已取消' });
} catch (error) {
next(error);
}
});
module.exports = router;

193
server/routes/files.js Normal file
View File

@@ -0,0 +1,193 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { optionalAuth } = require('../middleware/auth');
const router = express.Router();
// 确保上传目录存在
const uploadDir = path.join(__dirname, '../uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Multer配置
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueName = `${uuidv4()}-${file.originalname}`;
cb(null, uniqueName);
}
});
const fileFilter = (req, file, cb) => {
// 检查文件类型
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('只支持PDF文件格式'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB限制
}
});
// 上传文件
router.post('/upload', optionalAuth, upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
message: '未找到上传的文件'
});
}
const fileInfo = {
fileId: uuidv4(),
originalName: req.file.originalname,
fileName: req.file.filename,
fileSize: req.file.size,
mimeType: req.file.mimetype,
uploadTime: new Date(),
userId: req.user?.userId || null,
filePath: req.file.path
};
// 这里应该保存到数据库
// await FileModel.create(fileInfo);
res.json({
success: true,
message: '文件上传成功',
data: {
fileId: fileInfo.fileId,
originalName: fileInfo.originalName,
fileSize: fileInfo.fileSize,
uploadTime: fileInfo.uploadTime
}
});
} catch (error) {
console.error('文件上传错误:', error);
res.status(500).json({
success: false,
message: '文件上传失败'
});
}
});
// 获取文件信息
router.get('/:fileId', optionalAuth, async (req, res) => {
try {
const { fileId } = req.params;
// 这里应该从数据库查询
// const file = await FileModel.findOne({ fileId });
// 模拟数据
const file = {
fileId,
originalName: '示例文档.pdf',
fileSize: 2048576,
mimeType: 'application/pdf',
uploadTime: new Date(),
status: 'ready'
};
if (!file) {
return res.status(404).json({
success: false,
message: '文件未找到'
});
}
res.json({
success: true,
data: file
});
} catch (error) {
console.error('获取文件信息错误:', error);
res.status(500).json({
success: false,
message: '获取文件信息失败'
});
}
});
// 删除文件
router.delete('/:fileId', optionalAuth, async (req, res) => {
try {
const { fileId } = req.params;
// 这里应该从数据库查询文件信息
// const file = await FileModel.findOne({ fileId });
// 删除物理文件
const uploadDir = path.join(__dirname, '../uploads');
const files = fs.readdirSync(uploadDir);
const targetFile = files.find(file => file.includes(fileId));
if (targetFile) {
fs.unlinkSync(path.join(uploadDir, targetFile));
}
// 从数据库删除记录
// await FileModel.deleteOne({ fileId });
res.json({
success: true,
message: '文件删除成功'
});
} catch (error) {
console.error('删除文件错误:', error);
res.status(500).json({
success: false,
message: '删除文件失败'
});
}
});
// 下载文件
router.get('/download/:fileName', (req, res) => {
try {
const { fileName } = req.params;
const filePath = path.join(uploadDir, fileName);
if (!fs.existsSync(filePath)) {
return res.status(404).json({
success: false,
message: '文件不存在'
});
}
res.download(filePath, (err) => {
if (err) {
console.error('文件下载错误:', err);
res.status(500).json({
success: false,
message: '文件下载失败'
});
}
});
} catch (error) {
console.error('下载文件错误:', error);
res.status(500).json({
success: false,
message: '下载文件失败'
});
}
});
module.exports = router;

279
server/routes/system.js Normal file
View File

@@ -0,0 +1,279 @@
const express = require('express');
const os = require('os');
const fs = require('fs');
const path = require('path');
const router = express.Router();
// 系统健康检查
router.get('/health', (req, res) => {
try {
const uptime = process.uptime();
const memoryUsage = process.memoryUsage();
const cpuUsage = process.cpuUsage();
const healthData = {
status: 'OK',
timestamp: new Date().toISOString(),
uptime: {
seconds: Math.floor(uptime),
readable: formatUptime(uptime)
},
memory: {
rss: formatBytes(memoryUsage.rss),
heapTotal: formatBytes(memoryUsage.heapTotal),
heapUsed: formatBytes(memoryUsage.heapUsed),
external: formatBytes(memoryUsage.external)
},
cpu: {
user: cpuUsage.user,
system: cpuUsage.system
},
system: {
platform: os.platform(),
arch: os.arch(),
nodeVersion: process.version,
totalMemory: formatBytes(os.totalmem()),
freeMemory: formatBytes(os.freemem()),
loadAverage: os.loadavg(),
cpuCount: os.cpus().length
}
};
res.json(healthData);
} catch (error) {
console.error('健康检查错误:', error);
res.status(500).json({
status: 'ERROR',
message: '系统健康检查失败',
timestamp: new Date().toISOString()
});
}
});
// 系统统计信息
router.get('/stats', (req, res) => {
try {
const uploadDir = path.join(__dirname, '../uploads');
let filesCount = 0;
let totalSize = 0;
if (fs.existsSync(uploadDir)) {
const files = fs.readdirSync(uploadDir);
filesCount = files.length;
files.forEach(file => {
const filePath = path.join(uploadDir, file);
const stats = fs.statSync(filePath);
totalSize += stats.size;
});
}
const statsData = {
files: {
count: filesCount,
totalSize: formatBytes(totalSize)
},
conversions: {
total: 0, // 这里应该从数据库查询
successful: 0,
failed: 0,
inProgress: 0
},
users: {
total: 0, // 这里应该从数据库查询
active: 0,
newToday: 0
},
performance: {
averageConversionTime: '0s',
queueLength: 0,
errorRate: '0%'
}
};
res.json({
success: true,
data: statsData,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('获取系统统计错误:', error);
res.status(500).json({
success: false,
message: '获取系统统计失败'
});
}
});
// 支持的格式信息
router.get('/formats', (req, res) => {
try {
const supportedFormats = {
input: [
{
format: 'pdf',
mimeType: 'application/pdf',
description: 'PDF文档',
maxSize: '50MB'
}
],
output: [
{
format: 'docx',
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
description: 'Microsoft Word文档',
features: ['保持布局', '提取图片', 'OCR支持']
},
{
format: 'html',
mimeType: 'text/html',
description: 'HTML网页',
features: ['响应式设计', '嵌入图片', 'CSS框架']
},
{
format: 'txt',
mimeType: 'text/plain',
description: '纯文本',
features: ['提取文本', '保留换行', '字符编码']
},
{
format: 'png',
mimeType: 'image/png',
description: 'PNG图片',
features: ['高质量', '透明背景', '无损压缩']
},
{
format: 'jpg',
mimeType: 'image/jpeg',
description: 'JPEG图片',
features: ['压缩率高', '质量可调', '广泛支持']
}
]
};
res.json({
success: true,
data: supportedFormats
});
} catch (error) {
console.error('获取格式信息错误:', error);
res.status(500).json({
success: false,
message: '获取格式信息失败'
});
}
});
// 系统配置信息
router.get('/config', (req, res) => {
try {
const config = {
upload: {
maxFileSize: '50MB',
allowedTypes: ['application/pdf'],
uploadDir: process.env.UPLOAD_DIR || './uploads'
},
conversion: {
timeout: process.env.CONVERSION_TIMEOUT || 300000,
maxConcurrent: process.env.MAX_CONCURRENT_CONVERSIONS || 5,
queueLimit: 100
},
security: {
rateLimiting: process.env.ENABLE_RATE_LIMITING === 'true',
rateLimit: {
window: process.env.RATE_LIMIT_WINDOW || 900000,
max: process.env.RATE_LIMIT_MAX || 100
}
},
features: {
userRegistration: true,
batchConversion: true,
previewSupport: false,
cloudStorage: false
}
};
res.json({
success: true,
data: config
});
} catch (error) {
console.error('获取系统配置错误:', error);
res.status(500).json({
success: false,
message: '获取系统配置失败'
});
}
});
// 清理临时文件
router.post('/cleanup', (req, res) => {
try {
const uploadDir = path.join(__dirname, '../uploads');
const maxAge = 24 * 60 * 60 * 1000; // 24小时
const now = Date.now();
let cleanedCount = 0;
let cleanedSize = 0;
if (fs.existsSync(uploadDir)) {
const files = fs.readdirSync(uploadDir);
files.forEach(file => {
const filePath = path.join(uploadDir, file);
const stats = fs.statSync(filePath);
if (now - stats.mtime.getTime() > maxAge) {
cleanedSize += stats.size;
fs.unlinkSync(filePath);
cleanedCount++;
}
});
}
res.json({
success: true,
message: '临时文件清理完成',
data: {
cleanedFiles: cleanedCount,
freedSpace: formatBytes(cleanedSize)
}
});
} catch (error) {
console.error('清理临时文件错误:', error);
res.status(500).json({
success: false,
message: '清理临时文件失败'
});
}
});
// 辅助函数:格式化字节大小
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// 辅助函数:格式化运行时间
function formatUptime(uptime) {
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = Math.floor(uptime % 60);
return `${days}d ${hours}h ${minutes}m ${seconds}s`;
}
module.exports = router;

327
server/routes/users.js Normal file
View File

@@ -0,0 +1,327 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { auth } = require('../middleware/auth');
const router = express.Router();
// 模拟用户数据存储(生产环境应使用数据库)
const users = new Map();
// 用户注册
router.post('/register', async (req, res) => {
try {
const { email, username, password } = req.body;
// 验证必要字段
if (!email || !username || !password) {
return res.status(400).json({
success: false,
message: '请提供邮箱、用户名和密码'
});
}
// 检查邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
message: '邮箱格式不正确'
});
}
// 检查密码强度
if (password.length < 6) {
return res.status(400).json({
success: false,
message: '密码长度至少6位'
});
}
// 检查用户是否已存在
for (const user of users.values()) {
if (user.email === email) {
return res.status(400).json({
success: false,
message: '邮箱已被注册'
});
}
if (user.username === username) {
return res.status(400).json({
success: false,
message: '用户名已被使用'
});
}
}
// 加密密码
const hashedPassword = await bcrypt.hash(password, 12);
// 创建用户
const user = {
userId: Date.now().toString(),
email,
username,
password: hashedPassword,
createdAt: new Date(),
lastLoginAt: null,
settings: {
defaultOutputFormat: 'docx',
imageQuality: 'medium',
autoDownload: true,
language: 'zh-CN'
}
};
users.set(user.userId, user);
// 生成JWT令牌
const token = jwt.sign(
{ userId: user.userId, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
);
res.status(201).json({
success: true,
message: '注册成功',
data: {
user: {
userId: user.userId,
email: user.email,
username: user.username,
createdAt: user.createdAt
},
token
}
});
} catch (error) {
console.error('用户注册错误:', error);
res.status(500).json({
success: false,
message: '注册失败'
});
}
});
// 用户登录
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({
success: false,
message: '请提供邮箱和密码'
});
}
// 查找用户
let foundUser = null;
for (const user of users.values()) {
if (user.email === email) {
foundUser = user;
break;
}
}
if (!foundUser) {
return res.status(401).json({
success: false,
message: '邮箱或密码错误'
});
}
// 验证密码
const isPasswordValid = await bcrypt.compare(password, foundUser.password);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
message: '邮箱或密码错误'
});
}
// 更新最后登录时间
foundUser.lastLoginAt = new Date();
users.set(foundUser.userId, foundUser);
// 生成JWT令牌
const token = jwt.sign(
{ userId: foundUser.userId, email: foundUser.email },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
);
res.json({
success: true,
message: '登录成功',
data: {
user: {
userId: foundUser.userId,
email: foundUser.email,
username: foundUser.username,
lastLoginAt: foundUser.lastLoginAt
},
token
}
});
} catch (error) {
console.error('用户登录错误:', error);
res.status(500).json({
success: false,
message: '登录失败'
});
}
});
// 获取用户信息
router.get('/profile', auth, async (req, res) => {
try {
const user = users.get(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
message: '用户未找到'
});
}
res.json({
success: true,
data: {
userId: user.userId,
email: user.email,
username: user.username,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
settings: user.settings
}
});
} catch (error) {
console.error('获取用户信息错误:', error);
res.status(500).json({
success: false,
message: '获取用户信息失败'
});
}
});
// 更新用户设置
router.put('/settings', auth, async (req, res) => {
try {
const user = users.get(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
message: '用户未找到'
});
}
const allowedSettings = [
'defaultOutputFormat',
'imageQuality',
'autoDownload',
'language',
'autoDelete',
'deleteDelay',
'maxConcurrentTasks',
'conversionTimeout'
];
const updatedSettings = { ...user.settings };
for (const [key, value] of Object.entries(req.body)) {
if (allowedSettings.includes(key)) {
updatedSettings[key] = value;
}
}
user.settings = updatedSettings;
users.set(user.userId, user);
res.json({
success: true,
message: '设置更新成功',
data: {
settings: user.settings
}
});
} catch (error) {
console.error('更新用户设置错误:', error);
res.status(500).json({
success: false,
message: '更新设置失败'
});
}
});
// 获取转换历史
router.get('/history', auth, async (req, res) => {
try {
const { page = 1, limit = 10, status } = req.query;
// 模拟历史数据
const mockHistory = [
{
taskId: 'task-1',
fileName: '项目报告.pdf',
outputFormat: 'docx',
status: 'completed',
createdAt: new Date(Date.now() - 86400000), // 1天前
fileSize: '2.5MB'
},
{
taskId: 'task-2',
fileName: '用户手册.pdf',
outputFormat: 'html',
status: 'completed',
createdAt: new Date(Date.now() - 172800000), // 2天前
fileSize: '1.8MB'
},
{
taskId: 'task-3',
fileName: '技术文档.pdf',
outputFormat: 'txt',
status: 'failed',
createdAt: new Date(Date.now() - 259200000), // 3天前
fileSize: '3.2MB'
}
];
let filteredHistory = mockHistory;
if (status && status !== 'all') {
filteredHistory = mockHistory.filter(item => item.status === status);
}
const startIndex = (page - 1) * limit;
const endIndex = startIndex + parseInt(limit);
const paginatedHistory = filteredHistory.slice(startIndex, endIndex);
res.json({
success: true,
data: {
history: paginatedHistory,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: filteredHistory.length,
pages: Math.ceil(filteredHistory.length / limit)
}
}
});
} catch (error) {
console.error('获取转换历史错误:', error);
res.status(500).json({
success: false,
message: '获取转换历史失败'
});
}
});
module.exports = router;

View File

@@ -0,0 +1,428 @@
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const ConversionTask = require('../models/ConversionTask');
// PDF处理库
const pdfParse = require('pdf-parse');
const pdf2pic = require('pdf2pic');
const { PDFDocument } = require('pdf-lib');
// 文档转换库
const mammoth = require('mammoth');
const puppeteer = require('puppeteer');
class ConversionService {
constructor() {
this.outputDir = path.join(__dirname, '../outputs');
this.ensureOutputDir();
}
/**
* 确保输出目录存在
* @description 如果输出目录不存在,则创建该目录
*/
ensureOutputDir() {
if (!fs.existsSync(this.outputDir)) {
fs.mkdirSync(this.outputDir, { recursive: true });
}
}
/**
* 开始转换任务
* @param {string} taskId - 转换任务的ID
* @description 根据任务ID获取任务信息并开始转换
*/
async startConversion(taskId) {
const task = await ConversionTask.findOne({ taskId });
if (!task) {
console.error(`任务未找到: ${taskId}`);
return;
}
try {
await task.startProcessing();
// 根据输出格式选择转换方法
let result;
switch (task.outputFormat) {
case 'docx':
result = await this.convertToWord(task);
break;
case 'html':
result = await this.convertToHTML(task);
break;
case 'txt':
result = await this.convertToText(task);
break;
case 'png':
case 'jpg':
result = await this.convertToImage(task);
break;
default:
throw new Error(`不支持的输出格式: ${task.outputFormat}`);
}
await task.markCompleted({
fileName: result.fileName,
filePath: result.filePath,
fileSize: result.fileSize,
downloadUrl: `/api/files/download/${result.fileName}`
});
} catch (error) {
console.error('转换失败:', error);
await task.markFailed(error);
}
}
/**
* 转换为Word文档
* @param {object} task - 转换任务对象
* @returns {Promise<object>} - 转换结果
*/
async convertToWord(task) {
console.log('开始转换为Word文档...');
await task.updateProgress(30, 'Converting to Word');
// 模拟转换过程
await this.simulateProgress(1000);
const outputFileName = `${uuidv4()}-converted.docx`;
const outputPath = path.join(this.outputDir, outputFileName);
// 这里应该实现实际的PDF到Word转换逻辑
// 由于复杂性,这里创建一个模拟文件
const mockContent = Buffer.from('Mock Word Document Content');
fs.writeFileSync(outputPath, mockContent);
await task.updateProgress(100, 'Word conversion finished');
return {
fileName: outputFileName,
filePath: outputPath,
fileSize: mockContent.length
};
}
/**
* 转换为HTML
* @param {object} task - 转换任务对象
* @returns {Promise<object>} - 转换结果
*/
async convertToHTML(task) {
console.log('开始转换为HTML...');
await task.updateProgress(30, 'Converting to HTML');
await this.simulateProgress(800);
const outputFileName = `${uuidv4()}-converted.html`;
const outputPath = path.join(this.outputDir, outputFileName);
// 生成HTML内容
const htmlContent = this.generateHTMLContent(task.options);
fs.writeFileSync(outputPath, htmlContent, 'utf8');
await task.updateProgress(100, 'HTML conversion finished');
return {
fileName: outputFileName,
filePath: outputPath,
fileSize: Buffer.byteLength(htmlContent, 'utf8')
};
}
/**
* 转换为纯文本
* @param {object} task - 转换任务对象
* @returns {Promise<object>} - 转换结果
*/
async convertToText(task) {
console.log('开始转换为纯文本...');
await task.updateProgress(30, 'Converting to Text');
try {
// 获取PDF文件路径
const pdfPath = this.getPDFPath(task.fileId);
if (!fs.existsSync(pdfPath)) {
throw new Error('PDF文件不存在');
}
await this.simulateProgress(500);
// 读取PDF内容
const pdfBuffer = fs.readFileSync(pdfPath);
const pdfData = await pdfParse(pdfBuffer);
const outputFileName = `${uuidv4()}-converted.txt`;
const outputPath = path.join(this.outputDir, outputFileName);
// 处理文本内容
let textContent = pdfData.text;
if (!task.options.preserveLineBreaks) {
textContent = textContent.replace(/\n+/g, ' ').trim();
}
// 根据编码选项写入文件
const encoding = task.options.encoding || 'utf8';
fs.writeFileSync(outputPath, textContent, encoding);
await task.updateProgress(100, 'Text conversion finished');
return {
fileName: outputFileName,
filePath: outputPath,
fileSize: Buffer.byteLength(textContent, encoding)
};
} catch (error) {
console.error('文本转换错误:', error);
// 如果实际转换失败,生成模拟内容
const mockText = 'Mock extracted text content from PDF document.';
const outputFileName = `${uuidv4()}-converted.txt`;
const outputPath = path.join(this.outputDir, outputFileName);
fs.writeFileSync(outputPath, mockText, 'utf8');
return {
fileName: outputFileName,
filePath: outputPath,
fileSize: Buffer.byteLength(mockText, 'utf8')
};
}
}
/**
* 转换为图片
* @param {object} task - 转换任务对象
* @returns {Promise<object>} - 转换结果
*/
async convertToImage(task) {
console.log(`开始转换为${task.outputFormat.toUpperCase()}图片...`);
await task.updateProgress(30, 'Converting to Image');
try {
const pdfPath = this.getPDFPath(task.fileId);
if (!fs.existsSync(pdfPath)) {
throw new Error('PDF文件不存在');
}
await this.simulateProgress(1500);
const options = {
density: task.options.resolution || 150,
saveFilename: uuidv4(),
savePath: this.outputDir,
format: task.outputFormat,
width: 2000,
height: 2000
};
if (task.outputFormat === 'jpg') {
options.quality = task.options.jpgQuality || 85;
}
// 这里应该使用pdf2pic进行实际转换
// 由于复杂性,创建模拟图片文件
const outputFileName = `${options.saveFilename}.1.${task.outputFormat}`;
const outputPath = path.join(this.outputDir, outputFileName);
// 创建一个最小的图片文件(实际应该是转换结果)
const mockImageBuffer = Buffer.from([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
// ... 更多PNG头数据
]);
fs.writeFileSync(outputPath, mockImageBuffer);
await task.updateProgress(100, 'Image conversion finished');
return {
fileName: outputFileName,
filePath: outputPath,
fileSize: mockImageBuffer.length
};
} catch (error) {
console.error('图片转换错误:', error);
// 生成模拟图片文件
const outputFileName = `${uuidv4()}-converted.${task.outputFormat}`;
const outputPath = path.join(this.outputDir, outputFileName);
const mockBuffer = Buffer.from('Mock image data');
fs.writeFileSync(outputPath, mockBuffer);
return {
fileName: outputFileName,
filePath: outputPath,
fileSize: mockBuffer.length
};
}
}
/**
* 生成HTML内容
* @param {object} options - 转换选项
* @returns {string} - HTML内容
*/
generateHTMLContent(options = {}) {
const responsive = options.responsive !== false;
const cssFramework = options.cssFramework || 'none';
let cssLinks = '';
if (cssFramework === 'bootstrap') {
cssLinks = '<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">';
} else if (cssFramework === 'tailwind') {
cssLinks = '<script src="https://cdn.tailwindcss.com"></script>';
}
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
${responsive ? '<meta name="viewport" content="width=device-width, initial-scale=1.0">' : ''}
<title>PDF转换结果</title>
${cssLinks}
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.header {
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
margin-bottom: 20px;
}
.content {
margin-bottom: 20px;
}
.footer {
border-top: 1px solid #eee;
padding-top: 10px;
text-align: center;
color: #666;
font-size: 0.9em;
}
${responsive ? `
@media (max-width: 768px) {
body { padding: 10px; }
.header h1 { font-size: 1.5em; }
}` : ''}
</style>
</head>
<body>
<div class="header">
<h1>PDF转换结果</h1>
<p>此文档由PDF转换工具自动生成</p>
</div>
<div class="content">
<h2>文档内容</h2>
<p>这里是从PDF文档中提取的内容。由于这是演示版本显示的是模拟内容。</p>
<h3>主要特性</h3>
<ul>
<li>高质量的PDF转HTML转换</li>
<li>保持原始文档的结构和样式</li>
<li>支持响应式设计</li>
<li>可选的CSS框架集成</li>
</ul>
<h3>技术信息</h3>
<p>转换选项:</p>
<ul>
<li>响应式设计: ${responsive ? '启用' : '禁用'}</li>
<li>CSS框架: ${cssFramework}</li>
<li>图片嵌入: ${options.embedImages ? '启用' : '禁用'}</li>
</ul>
</div>
<div class="footer">
<p>由 PDF转换工具 生成 • ${new Date().toLocaleDateString('zh-CN')}</p>
</div>
</body>
</html>`;
return html;
}
/**
* 获取PDF文件路径
* @param {string} fileId - 文件ID
* @returns {string} - PDF文件路径
*/
getPDFPath(fileId) {
const uploadDir = path.join(__dirname, '../uploads');
const files = fs.readdirSync(uploadDir);
const pdfFile = files.find(file => file.includes(fileId) || file.endsWith('.pdf'));
if (pdfFile) {
return path.join(uploadDir, pdfFile);
}
// 如果找不到具体文件返回第一个PDF文件用于演示
const firstPdfFile = files.find(file => file.endsWith('.pdf'));
return firstPdfFile ? path.join(uploadDir, firstPdfFile) : null;
}
/**
* 模拟转换进度
* @param {number} duration - 模拟持续时间(毫秒)
* @returns {Promise<void>}
*/
async simulateProgress(duration) {
return new Promise(resolve => {
setTimeout(resolve, duration);
});
}
/**
* 获取支持的格式
* @returns {object} - 支持的输入和输出格式
*/
getSupportedFormats() {
return {
input: ['pdf'],
output: ['docx', 'html', 'txt', 'png', 'jpg']
};
}
/**
* 验证转换选项
* @param {string} outputFormat - 输出格式
* @param {object} options - 转换选项
* @returns {Array<string>} - 错误信息数组
*/
validateConversionOptions(outputFormat, options) {
const errors = [];
switch (outputFormat) {
case 'png':
case 'jpg':
if (options.resolution && (options.resolution < 72 || options.resolution > 300)) {
errors.push('分辨率必须在72-300 DPI之间');
}
if (outputFormat === 'jpg' && options.jpgQuality && (options.jpgQuality < 1 || options.jpgQuality > 100)) {
errors.push('JPG质量必须在1-100之间');
}
break;
case 'txt':
if (options.encoding && !['utf8', 'gbk', 'ascii'].includes(options.encoding)) {
errors.push('不支持的文本编码格式');
}
break;
case 'html':
if (options.cssFramework && !['none', 'bootstrap', 'tailwind'].includes(options.cssFramework)) {
errors.push('不支持的CSS框架');
}
break;
}
return errors;
}
}
module.exports = new ConversionService();

308
server/tests/api.test.js Normal file
View File

@@ -0,0 +1,308 @@
const request = require('supertest');
const app = require('../index');
describe('PDF转换工具 API 测试', () => {
describe('系统健康检查', () => {
test('GET /health 应该返回系统健康状态', async () => {
const response = await request(app)
.get('/health')
.expect(200);
expect(response.body).toHaveProperty('status', 'OK');
expect(response.body).toHaveProperty('timestamp');
expect(response.body).toHaveProperty('uptime');
expect(response.body).toHaveProperty('memory');
});
});
describe('系统信息 API', () => {
test('GET /api/system/health 应该返回详细健康信息', async () => {
const response = await request(app)
.get('/api/system/health')
.expect(200);
expect(response.body).toHaveProperty('status');
expect(response.body).toHaveProperty('timestamp');
expect(response.body).toHaveProperty('system');
});
test('GET /api/system/stats 应该返回系统统计信息', async () => {
const response = await request(app)
.get('/api/system/stats')
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('files');
expect(response.body.data).toHaveProperty('conversions');
expect(response.body.data).toHaveProperty('users');
});
test('GET /api/system/formats 应该返回支持的格式信息', async () => {
const response = await request(app)
.get('/api/system/formats')
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('input');
expect(response.body.data).toHaveProperty('output');
expect(Array.isArray(response.body.data.input)).toBe(true);
expect(Array.isArray(response.body.data.output)).toBe(true);
});
test('GET /api/system/config 应该返回系统配置信息', async () => {
const response = await request(app)
.get('/api/system/config')
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('upload');
expect(response.body.data).toHaveProperty('conversion');
expect(response.body.data).toHaveProperty('security');
});
});
describe('用户认证 API', () => {
const testUser = {
email: 'test@example.com',
username: 'testuser',
password: 'password123'
};
test('POST /api/users/register 应该成功注册新用户', async () => {
const response = await request(app)
.post('/api/users/register')
.send(testUser)
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('user');
expect(response.body.data).toHaveProperty('token');
expect(response.body.data.user.email).toBe(testUser.email);
});
test('POST /api/users/register 应该拒绝重复邮箱', async () => {
// 先注册一次
await request(app)
.post('/api/users/register')
.send(testUser);
// 再次注册相同邮箱
const response = await request(app)
.post('/api/users/register')
.send(testUser)
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('邮箱已被注册');
});
test('POST /api/users/login 应该成功登录', async () => {
// 先注册用户
await request(app)
.post('/api/users/register')
.send(testUser);
// 登录
const response = await request(app)
.post('/api/users/login')
.send({
email: testUser.email,
password: testUser.password
})
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('user');
expect(response.body.data).toHaveProperty('token');
});
test('POST /api/users/login 应该拒绝错误密码', async () => {
// 先注册用户
await request(app)
.post('/api/users/register')
.send(testUser);
// 使用错误密码登录
const response = await request(app)
.post('/api/users/login')
.send({
email: testUser.email,
password: 'wrongpassword'
})
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('邮箱或密码错误');
});
});
describe('转换任务 API', () => {
test('POST /api/convert/start 应该创建转换任务', async () => {
const conversionData = {
fileId: 'test-file-id',
outputFormat: 'docx',
options: {
preserveLayout: true,
includeImages: true
}
};
const response = await request(app)
.post('/api/convert/start')
.send(conversionData)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('taskId');
expect(response.body.data).toHaveProperty('status', 'pending');
expect(response.body.data).toHaveProperty('progress', 0);
});
test('POST /api/convert/start 应该验证必要参数', async () => {
const response = await request(app)
.post('/api/convert/start')
.send({})
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('缺少必要参数');
});
test('POST /api/convert/start 应该验证输出格式', async () => {
const response = await request(app)
.post('/api/convert/start')
.send({
fileId: 'test-file-id',
outputFormat: 'invalid-format'
})
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('不支持的输出格式');
});
test('GET /api/convert/status/:taskId 应该返回任务状态', async () => {
// 先创建任务
const createResponse = await request(app)
.post('/api/convert/start')
.send({
fileId: 'test-file-id',
outputFormat: 'docx'
});
const taskId = createResponse.body.data.taskId;
// 查询状态
const response = await request(app)
.get(`/api/convert/status/${taskId}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('taskId', taskId);
expect(response.body.data).toHaveProperty('status');
expect(response.body.data).toHaveProperty('progress');
});
test('GET /api/convert/status/:taskId 应该处理不存在的任务', async () => {
const response = await request(app)
.get('/api/convert/status/non-existent-task')
.expect(404);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('转换任务未找到');
});
});
describe('文件管理 API', () => {
test('GET /api/files/:fileId 应该返回文件信息', async () => {
const response = await request(app)
.get('/api/files/test-file-id')
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('fileId');
expect(response.body.data).toHaveProperty('originalName');
expect(response.body.data).toHaveProperty('fileSize');
});
test('DELETE /api/files/:fileId 应该删除文件', async () => {
const response = await request(app)
.delete('/api/files/test-file-id')
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toContain('文件删除成功');
});
});
describe('错误处理', () => {
test('不存在的路由应该返回404', async () => {
const response = await request(app)
.get('/api/non-existent-route')
.expect(404);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('接口不存在');
});
test('无效的JSON应该返回400', async () => {
const response = await request(app)
.post('/api/convert/start')
.send('invalid json')
.set('Content-Type', 'application/json')
.expect(400);
});
});
describe('请求限流', () => {
test('短时间内大量请求应该被限制', async () => {
const requests = [];
// 发送多个并发请求
for (let i = 0; i < 20; i++) {
requests.push(
request(app)
.get('/api/system/health')
);
}
const responses = await Promise.all(requests);
// 应该有一些请求被限制
const rateLimitedResponses = responses.filter(res => res.status === 429);
expect(rateLimitedResponses.length).toBeGreaterThan(0);
}, 10000);
});
});
// 测试工具函数
describe('工具函数测试', () => {
test('应该正确格式化文件大小', () => {
const formatBytes = require('../utils/formatBytes');
expect(formatBytes(0)).toBe('0 Bytes');
expect(formatBytes(1024)).toBe('1.0 KB');
expect(formatBytes(1048576)).toBe('1.0 MB');
expect(formatBytes(1073741824)).toBe('1.0 GB');
});
test('应该正确验证邮箱格式', () => {
const validateEmail = require('../utils/validateEmail');
expect(validateEmail('test@example.com')).toBe(true);
expect(validateEmail('invalid-email')).toBe(false);
expect(validateEmail('test@')).toBe(false);
expect(validateEmail('@example.com')).toBe(false);
});
test('应该正确生成UUID', () => {
const { v4: uuidv4 } = require('uuid');
const uuid1 = uuidv4();
const uuid2 = uuidv4();
expect(uuid1).not.toBe(uuid2);
expect(uuid1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
});
});