234 lines
6.1 KiB
JavaScript
234 lines
6.1 KiB
JavaScript
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
|
|
}
|