import { existsSync, mkdirSync, unlinkSync } from 'fs' import { writeFile } from 'fs/promises' import { join } from 'path' import { v4 as uuidv4 } from 'uuid' import { processImage, getImageMetadata } from './image.js' import db from './db.js' /** * 根据配置处理图片格式转换/压缩 * 压缩与格式转换相互独立: * - enableCompression:仅控制是否压缩(按质量压缩),不影响格式转换 * - convertToWebp / convertToPng / convertToJpg:格式转换独立开关(三选一,webp > jpg > png 优先级) * @param {Buffer} buffer - 原始图片数据 * @param {string} fileExt - 原始文件扩展名 * @param {Object} config - 配置对象 * @param {boolean} config.enableCompression - 是否开启压缩 * @param {number} config.compressionQuality - 压缩质量 (0-100) * @param {boolean} config.convertToWebp - 是否转换为 WebP * @param {boolean} config.convertToPng - 是否转换为 PNG * @param {boolean} config.convertToJpg - 是否转换为 JPG * @param {number} [minSizeKb=0] - 触发压缩的最小文件大小(KB),0 表示不限 * @returns {Promise<{processedBuffer: Buffer, finalFormat: string, isWebp: boolean}>} */ export async function processImageWithConfig(buffer, fileExt, config, minSizeKb = 0) { let processedBuffer = buffer let finalFormat = fileExt let isWebp = false const isGif = fileExt === 'gif' const fileSizeInKb = buffer.length / 1024 // 确定目标格式(格式转换独立于压缩) let targetFormat = fileExt if (!isGif) { if (config.convertToWebp) { targetFormat = 'webp' isWebp = true } else if (config.convertToJpg) { targetFormat = 'jpg' } else if (config.convertToPng) { targetFormat = 'png' } } finalFormat = targetFormat // 判断是否需要格式转换 const needConvert = !isGif && targetFormat !== fileExt // 判断是否需要压缩(低于 100KB 的图片不执行压缩,格式转换不受此限制) const compressMinSizeKb = minSizeKb > 0 ? minSizeKb : 100 const needCompress = config.enableCompression && !isGif && fileSizeInKb > compressMinSizeKb if (needConvert || needCompress) { const processOptions = { format: targetFormat === 'jpg' ? 'jpeg' : targetFormat, quality: needCompress ? (config.compressionQuality || 80) : 100 } processedBuffer = await processImage(buffer, processOptions) } return { processedBuffer, finalFormat, isWebp } } // 上传目录:生产环境使用 /app/uploads,开发环境使用项目根目录下的 uploads const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : join(process.cwd(), 'uploads') // 确保上传目录存在 if (!existsSync(uploadsDir)) { mkdirSync(uploadsDir, { recursive: true }) } console.log('[Upload] 上传目录:', uploadsDir) /** * 解析 multipart/form-data 请求 */ export async function parseFormData(event) { const formData = await readMultipartFormData(event) if (!formData || formData.length === 0) { return { file: null } } // 查找文件字段 const fileField = formData.find(field => field.name === 'file' || field.name === 'image') if (!fileField || !fileField.data) { return { file: null } } return { file: { buffer: fileField.data, originalFilename: fileField.filename || 'unknown', mimetype: fileField.type, size: fileField.data.length } } } /** * 获取文件扩展名(内部使用) */ function getFileExtension(filename) { const ext = filename.split('.').pop().toLowerCase() return ext } /** * 验证文件格式 */ export function validateFormat(filename, allowedFormats) { const ext = getFileExtension(filename) return allowedFormats.map(f => f.toLowerCase()).includes(ext) } /** * 验证文件大小 */ export function validateSize(size, maxSize) { return size <= maxSize } /** * 保存图片文件 * @param {Buffer} buffer - 图片数据 * @param {Object} options - 配置选项 * @param {string} options.originalName - 原始文件名 * @param {boolean} options.convertToWebp - 是否转换为 WebP * @param {number} options.webpQuality - WebP 质量 * @param {string} options.uploadedBy - 上传者 * @param {string} options.ip - 上传者 IP * @param {boolean} options.isPublic - 是否为公共上传 */ export async function saveUploadedImage(buffer, options) { const { originalName, convertToWebp = false, webpQuality = 80, uploadedBy = '访客', ip = '', isPublic = true } = options const uuid = uuidv4() const originalExt = getFileExtension(originalName) let finalBuffer = buffer let finalExt = originalExt let isWebp = false // 如果需要转换为 WebP if (convertToWebp && originalExt !== 'gif') { finalBuffer = await processImage(buffer, { format: 'webp', quality: webpQuality }) finalExt = 'webp' isWebp = true } // 获取图片信息 const imageInfo = await getImageMetadata(finalBuffer) // 生成文件名和路径 const filename = `${uuid}.${finalExt}` const filepath = join(uploadsDir, filename) // 保存文件 await writeFile(filepath, finalBuffer) // 保存到数据库 const imageRecord = { _id: uuidv4(), uuid, originalName, filename, size: finalBuffer.length, format: finalExt, width: imageInfo.width, height: imageInfo.height, isWebp, isPublic, uploadedBy, ip, isDeleted: false, uploadedAt: new Date().toISOString(), updatedAt: new Date().toISOString() } await db.images.insert(imageRecord) // 返回图片信息(不包含敏感信息) return { uuid, filename, format: finalExt, size: finalBuffer.length, width: imageInfo.width, height: imageInfo.height, url: `/i/${uuid}.${finalExt}` } } /** * 删除图片文件 */ export async function deleteImageFile(filename) { const filepath = join(uploadsDir, filename) if (existsSync(filepath)) { unlinkSync(filepath) return true } return false } /** * 获取图片文件路径 */ export function getImagePath(filename) { return join(uploadsDir, filename) } /** * 获取上传目录路径 */ export function getUploadsDirPath() { return uploadsDir }