You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
151 lines
4.3 KiB
151 lines
4.3 KiB
import multer from 'multer';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import sharp from 'sharp';
|
|
import { callNodeListener } from 'h3';
|
|
import { R } from '#server/utils/response';
|
|
import { MEDIA_IMAGE_MAX_WIDTH_PX, MEDIA_WEBP_QUALITY } from '#server/constants/media';
|
|
import { insertMediaAssetRow } from '#server/service/media';
|
|
|
|
// 类型定义
|
|
interface IFile {
|
|
name: string;
|
|
url: string; // 前端可直接访问的 URL
|
|
path: string; // 服务器存储路径(最终 WebP)
|
|
mimeType: string;
|
|
size: number;
|
|
}
|
|
|
|
type MulterUploadedFile = {
|
|
originalname: string;
|
|
filename: string;
|
|
path: string;
|
|
mimetype: string;
|
|
size: number;
|
|
};
|
|
|
|
export default defineWrappedResponseHandler(async (event) => {
|
|
try {
|
|
const user = await event.context.auth.requireUser();
|
|
|
|
// 存储目录
|
|
const uploadDir = path.join(process.cwd(), 'public/assets');
|
|
|
|
// 自动创建目录
|
|
if (!fs.existsSync(uploadDir)) {
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
}
|
|
|
|
// 配置存储
|
|
const storage = multer.diskStorage({
|
|
destination: uploadDir,
|
|
filename: (req, file, cb) => {
|
|
// 生成唯一文件名:时间戳 + 原始文件名(安全处理)
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
|
// 获取文件后缀
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
// 干净的文件名(只保留字母数字)
|
|
const baseName = path.basename(file.originalname, ext).replace(/[^a-z0-9]/gi, '-');
|
|
// 最终文件名(带后缀)
|
|
const filename = `${uniqueSuffix}-${baseName}${ext}`;
|
|
cb(null, filename);
|
|
},
|
|
});
|
|
|
|
// 上传配置
|
|
const upload = multer({
|
|
storage,
|
|
limits: {
|
|
fileSize: 10 * 1024 * 1024, // 10MB 限制
|
|
},
|
|
fileFilter: (req, file, cb) => {
|
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
|
if (allowedTypes.includes(file.mimetype)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('只支持 PNG/JPG/WebP 格式图片'));
|
|
}
|
|
},
|
|
});
|
|
|
|
// 执行上传(最多 10 个文件)
|
|
await callNodeListener(
|
|
// @ts-expect-error Nuxt 类型兼容
|
|
upload.array('file', 10),
|
|
event.node.req,
|
|
event.node.res
|
|
);
|
|
|
|
// 获取上传后的文件
|
|
// @ts-expect-error
|
|
const uploadedFiles: MulterUploadedFile[] = event.node.req.files || [];
|
|
|
|
if (!Array.isArray(uploadedFiles) || uploadedFiles.length === 0) {
|
|
throw createError({ statusCode: 400, statusMessage: '请选择要上传的图片' });
|
|
}
|
|
|
|
const result: IFile[] = [];
|
|
|
|
for (const file of uploadedFiles) {
|
|
const stem = path.parse(file.filename).name;
|
|
const finalName = `${stem}.webp`;
|
|
const finalPath = path.join(uploadDir, finalName);
|
|
|
|
try {
|
|
await sharp(file.path)
|
|
.rotate()
|
|
.resize({
|
|
width: MEDIA_IMAGE_MAX_WIDTH_PX,
|
|
height: MEDIA_IMAGE_MAX_WIDTH_PX,
|
|
fit: 'inside',
|
|
withoutEnlargement: true,
|
|
})
|
|
.webp({ quality: MEDIA_WEBP_QUALITY })
|
|
.toFile(finalPath);
|
|
} catch (procErr) {
|
|
if (fs.existsSync(finalPath)) {
|
|
try {
|
|
fs.unlinkSync(finalPath);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
const msg = procErr instanceof Error ? procErr.message : '图片处理失败';
|
|
throw createError({ statusCode: 400, statusMessage: msg });
|
|
}
|
|
|
|
if (file.path !== finalPath) {
|
|
try {
|
|
fs.unlinkSync(file.path);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
const stat = fs.statSync(finalPath);
|
|
await insertMediaAssetRow({
|
|
userId: user.id,
|
|
storageKey: finalName,
|
|
mime: 'image/webp',
|
|
sizeBytes: stat.size,
|
|
});
|
|
|
|
result.push({
|
|
name: file.originalname,
|
|
url: `/public/assets/${finalName}`,
|
|
mimeType: 'image/webp',
|
|
size: stat.size,
|
|
path: finalPath,
|
|
});
|
|
}
|
|
|
|
return R.success({ files: result });
|
|
} catch (err: unknown) {
|
|
console.error('上传失败:', err);
|
|
if (err && typeof err === 'object' && 'statusCode' in err) {
|
|
throw err;
|
|
}
|
|
const msg = err instanceof Error ? err.message : '上传失败';
|
|
throw createError({ statusCode: 400, statusMessage: msg });
|
|
}
|
|
});
|
|
|