diff --git a/server/api/file/upload.post.ts b/server/api/file/upload.post.ts index 5011408..4ca8f6b 100644 --- a/server/api/file/upload.post.ts +++ b/server/api/file/upload.post.ts @@ -2,85 +2,69 @@ import multer from 'multer'; import fs from 'node:fs'; import path from 'node:path'; import { callNodeListener } from 'h3'; +import { + RELATIVE_ASSETS_DIR, + POST_MEDIA_PUBLIC_PREFIX, + ALLOWED_MIME_TYPES, + MAX_FILE_SIZE, + MAX_FILE_COUNT, +} from '#server/constants/upload'; +import { getContextUser } from '#server/utils/context'; -// 类型定义 interface IFile { name: string; - url: string; // 前端可直接访问的 URL - path: string; // 服务器存储路径 + url: string; mimeType: string; size: number; } -export default defineWrappedResponseHandler({ auth: 'optional' }, async (event) => { - try { - // 存储目录 - const uploadDir = path.join(process.cwd(), 'public/assets'); +export default defineWrappedResponseHandler({ auth: 'required' }, async (event) => { + const user = getContextUser(event)!; - // 自动创建目录 - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }); - } + const uploadDir = path.resolve(RELATIVE_ASSETS_DIR); - // 配置存储 - 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); - }, - }); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } - // 上传配置 - const upload = multer({ - storage, - limits: { - fileSize: 5 * 1024 * 1024, // 5MB 限制 - }, - 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 格式图片')); - } - }, - }); + 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, '-'); + cb(null, `${uniqueSuffix}-${baseName}${ext}`); + }, + }); - // 执行上传(最多 10 个文件) - await callNodeListener( - // @ts-expect-error Nuxt 类型兼容 - upload.array('file', 10), - event.node.req, - event.node.res - ); + const upload = multer({ + storage, + limits: { fileSize: MAX_FILE_SIZE }, + fileFilter: (_req, file, cb) => { + if ((ALLOWED_MIME_TYPES as readonly string[]).includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('只支持 PNG/JPG/WebP 格式图片')); + } + }, + }); - // 获取上传后的文件 - // @ts-expect-error - const uploadedFiles = event.node.req.files || []; + await callNodeListener( + // @ts-expect-error Nuxt 类型兼容 + upload.array('file', MAX_FILE_COUNT), + event.node.req, + event.node.res, + ); - // 格式化返回数据 - const result: IFile[] = uploadedFiles.map((file: any) => ({ - name: file.originalname, - url: `/public/assets/${file.filename}`, // ✅ 前端可直接访问 - mimeType: file.mimetype, - size: file.size, - })); + // @ts-expect-error + const uploadedFiles = event.node.req.files || []; - return result; + const result: IFile[] = uploadedFiles.map((file: any) => ({ + name: file.originalname, + url: `${POST_MEDIA_PUBLIC_PREFIX}${file.filename}`, + mimeType: file.mimetype, + size: file.size, + })); - } catch (err: any) { - console.error('上传失败:', err); - return createError({ - statusCode: 400, - statusMessage: err.message || '上传失败', - }); - } + return result; }); diff --git a/server/constants/upload.ts b/server/constants/upload.ts index 6fee1b9..aa06503 100644 --- a/server/constants/upload.ts +++ b/server/constants/upload.ts @@ -54,3 +54,12 @@ export const RELATIVE_ASSETS_DIR = path.join(STATIC_DIR, UPLOAD_SUBDIR); /** 与 `media` 返回及静态路径一致,无前导 host */ export const POST_MEDIA_PUBLIC_PREFIX = `${STATIC_PUBLIC_PREFIX}/${UPLOAD_SUBDIR}/`; + +/** 允许的上传文件 MIME 类型 */ +export const ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'] as const; + +/** 单文件最大大小(字节) */ +export const MAX_FILE_SIZE = 5 * 1024 * 1024; + +/** 单次上传最大文件数 */ +export const MAX_FILE_COUNT = 10; diff --git a/server/utils/handler.ts b/server/utils/handler.ts index 63e5c92..8e3a898 100644 --- a/server/utils/handler.ts +++ b/server/utils/handler.ts @@ -1,10 +1,12 @@ import log4js from "logger"; import { getUserFromEvent } from "#server/utils/jwt"; import { getCurrentUser } from "#server/service/auth"; -import { setContextUser } from "#server/utils/context"; +import { setContextUser, getContextUser } from "#server/utils/context"; interface IConfig { auth?: 'required' | 'public' | 'optional'; + /** 允许的角色列表,不指定则不校验角色 */ + role?: string | string[]; } const defaultConfig: IConfig = { @@ -42,6 +44,19 @@ export const defineWrappedResponseHandler = ( } // ---- end auth guard ---- + // ---- role guard ---- + if (config.role) { + const user = getContextUser(event); + if (!user) { + return R.error("未登录", null); + } + const allowedRoles = Array.isArray(config.role) ? config.role : [config.role]; + if (!allowedRoles.includes(user.role)) { + return R.error("无权限", null); + } + } + // ---- end role guard ---- + const response = await handler(event) return response })