From c1199e16495d46606077e187edd4121a5a5a9713 Mon Sep 17 00:00:00 2001 From: Jerry Date: Mon, 12 May 2025 13:53:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + pnpm-lock.yaml | 121 +++++++++++++++++++++++++++ src/modules/image/file.controller.ts | 61 +++++++++++++- src/modules/image/file.service.ts | 39 ++++++++- src/utils/core/path.ts | 5 +- 5 files changed, 222 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 75855a6..a5401a7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "express": "^4.18.0", "ioredis": "^5.6.0", "mkdirp": "^3.0.1", + "multer": "1.4.5-lts.2", "simple-git": "^3.27.0", "uuid": "^11.1.0", "ws": "^8.18.1" @@ -22,6 +23,7 @@ "@types/compression": "^1.7.5", "@types/express": "^4.17.0", "@types/mkdirp": "^2.0.0", + "@types/multer": "^1.4.12", "@types/node": "^18.0.0", "@types/ws": "^8.18.1", "prettier": "^3.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1b5e22..947ac52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: mkdirp: specifier: ^3.0.1 version: 3.0.1 + multer: + specifier: 1.4.5-lts.2 + version: 1.4.5-lts.2 simple-git: specifier: ^3.27.0 version: 3.27.0 @@ -48,6 +51,9 @@ importers: '@types/mkdirp': specifier: ^2.0.0 version: 2.0.0 + '@types/multer': + specifier: ^1.4.12 + version: 1.4.12 '@types/node': specifier: ^18.0.0 version: 18.19.86 @@ -126,6 +132,9 @@ packages: resolution: {integrity: sha512-c/iUqMymAlxLAyIK3u5SzrwkrkyOdv1XDc91T+b5FsY7Jr6ERhUD19jJHOhPW4GD6tmN6mFEorfSdks525pwdQ==} deprecated: This is a stub types definition. mkdirp provides its own type definitions, so you do not need this installed. + '@types/multer@1.4.12': + resolution: {integrity: sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==} + '@types/node@18.19.86': resolution: {integrity: sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ==} @@ -171,6 +180,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -204,6 +216,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -250,6 +266,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -265,6 +285,9 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -473,6 +496,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -516,6 +542,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -532,6 +562,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multer@1.4.5-lts.2: + resolution: {integrity: sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==} + engines: {node: '>= 6.0.0'} + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -544,6 +578,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -582,6 +620,9 @@ packages: engines: {node: '>=14'} hasBin: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -601,6 +642,9 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -623,6 +667,9 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -673,6 +720,13 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -733,6 +787,9 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.8.2: resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} engines: {node: '>=14.17'} @@ -745,6 +802,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -851,6 +911,10 @@ snapshots: dependencies: mkdirp: 3.0.1 + '@types/multer@1.4.12': + dependencies: + '@types/express': 4.17.21 + '@types/node@18.19.86': dependencies: undici-types: 5.26.5 @@ -898,6 +962,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + append-field@1.0.0: {} + arg@4.1.3: {} array-flatten@1.1.1: {} @@ -944,6 +1010,10 @@ snapshots: buffer-from@1.1.2: {} + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + bytes@3.1.2: {} call-bind-apply-helpers@1.0.2: @@ -1003,6 +1073,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@1.6.2: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -1013,6 +1090,8 @@ snapshots: cookie@0.7.1: {} + core-util-is@1.0.3: {} + create-require@1.1.1: {} debug@2.6.9: @@ -1238,6 +1317,8 @@ snapshots: is-number@7.0.0: {} + isarray@1.0.0: {} + lodash.defaults@4.2.0: {} lodash.isarguments@3.1.0: {} @@ -1266,6 +1347,10 @@ snapshots: minimist@1.2.8: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mkdirp@1.0.4: {} mkdirp@3.0.1: {} @@ -1274,12 +1359,24 @@ snapshots: ms@2.1.3: {} + multer@1.4.5-lts.2: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + negotiator@0.6.3: {} negotiator@0.6.4: {} normalize-path@3.0.0: {} + object-assign@4.1.1: {} + object-inspect@1.13.4: {} on-finished@2.4.1: @@ -1304,6 +1401,8 @@ snapshots: prettier@3.5.3: {} + process-nextick-args@2.0.1: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -1324,6 +1423,16 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -1344,6 +1453,8 @@ snapshots: dependencies: glob: 7.2.3 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -1424,6 +1535,12 @@ snapshots: statuses@2.0.1: {} + streamsearch@1.1.0: {} + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-bom@3.0.0: {} strip-json-comments@2.0.1: {} @@ -1490,12 +1607,16 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + typedarray@0.0.6: {} + typescript@5.8.2: {} undici-types@5.26.5: {} unpipe@1.0.0: {} + util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} uuid@11.1.0: {} diff --git a/src/modules/image/file.controller.ts b/src/modules/image/file.controller.ts index c714c2e..88974cb 100644 --- a/src/modules/image/file.controller.ts +++ b/src/modules/image/file.controller.ts @@ -2,14 +2,21 @@ import express from 'express'; import FileService from './file.service'; import logger from '../../utils/core/logger'; import response from '../../utils/core/response'; +import paths from '../../utils/core/path'; +import multer from 'multer'; + +const uploadDir = paths.get('uploads'); +const upload = multer({ + dest: uploadDir, +}); class FileController { private readonly router: express.Router; - private readonly imageService: FileService; + private readonly FileService: FileService; constructor() { this.router = express.Router(); - this.imageService = new FileService(); + this.FileService = new FileService(); this.initializeRoutes(); } @@ -19,13 +26,19 @@ class FileController { private initializeRoutes(): void { this.router.get('*', this.handleGetFile); + this.router.post('/upload', upload.single('file'), this.handleUploadFile); } + /** + * get文件 + * @param req + * @param res + */ private handleGetFile = async (req: express.Request, res: express.Response): Promise => { try { const fullPath = req.params[0]; logger.debug(`有个小可爱正在请求${fullPath}噢..`); - const filePath = await this.imageService.getFile(fullPath); + const filePath = await this.FileService.getFile(fullPath); if (!filePath) { logger.warn(`${fullPath}:文件不存在..`); await response.error(res, '文件不存在啦!', 404); @@ -39,6 +52,48 @@ class FileController { logger.error('晶灵数据请求处理失败:', error); } }; + + /** + * 文件上传接口 + * @example 示例请求 + * ```js + * const form = new FormData(); + * const fileStream = fs.createReadStream(filePath); + * form.append('file', fileStream); + * const uploadUrl = `http://localhost:4000/upload?dir=${uploadDir}`; + * const response = await axios.post(uploadUrl, form, { + * headers: { + * ...form.getHeaders(), + * }, + * maxContentLength: Infinity, + * maxBodyLength: Infinity, + * }); + * ``` + * @param req + * @param res + */ + private handleUploadFile = async (req: express.Request, res: express.Response): Promise => { + try { + if (!req.file) { + await response.error(res, `未检测到上传文件`, 400); + return; + } + + const uploadDir = req.query.dir?.toString() || ''; + const { fullPath, relativePath } = await this.FileService.saveUploadedFile( + req.file, + uploadDir + ); + await response.success(res, { + message: '文件上传成功..', + filePath: fullPath, + url: relativePath, + }); + } catch (e) { + await response.error(res, `文件上传失败..`, 500); + logger.error(e); + } + }; } export default new FileController(); diff --git a/src/modules/image/file.service.ts b/src/modules/image/file.service.ts index 46732de..2b789cc 100644 --- a/src/modules/image/file.service.ts +++ b/src/modules/image/file.service.ts @@ -1,7 +1,8 @@ import path from 'path'; -import fs from 'fs'; +import fs from 'fs/promises'; import paths from '../../utils/core/path'; import logger from '../../utils/core/logger'; +import { existsSync } from 'fs'; class FileService { private readonly filePath: string; @@ -11,6 +12,10 @@ class FileService { logger.info(`晶灵云图数据中心初始化..数据存储在: ${this.filePath}`); } + /** + * 获取文件 + * @param relativePath 文件绝对路径 + */ public async getFile(relativePath: string): Promise { if (!this.isValidPath(relativePath) && !this.isValidFilename(path.basename(relativePath))) { throw new Error('非法路径请求'); @@ -19,9 +24,39 @@ class FileService { const filePath = path.join(this.filePath, relativePath); logger.debug(`尝试访问文件路径: ${filePath}`); - return fs.existsSync(filePath) ? filePath : null; + return existsSync(filePath) ? filePath : null; } + /** + * 保存文件到目标目录 + * @param file multer的file + * @param dir 可选上传目录,相对目录 + */ + public async saveUploadedFile( + file: Express.Multer.File, + dir: string = '' + ): Promise<{ fullPath: string; relativePath: string }> { + const baseDir = paths.get('uploads'); + const targetDir = path.join(baseDir, dir); + if (!existsSync(targetDir)) { + await fs.mkdir(targetDir, { recursive: true }); + logger.debug(`已创建上传目录: ${targetDir}`); + } + const fileName = `${Date.now()}-${file.originalname.replace(/\s+/g, '_')}`; + const finalPath = path.join(targetDir, fileName); + await fs.rename(file.path, finalPath); + logger.info(`保存上传文件: ${finalPath}`); + return { + fullPath: finalPath, + relativePath: `uploads/${dir}/${fileName}`, + }; + } + + /** + * 检查路径合法性 + * @param relativePath + * @private + */ private isValidPath(relativePath: string): boolean { try { const normalized = path.normalize(relativePath); diff --git a/src/utils/core/path.ts b/src/utils/core/path.ts index 2d4d903..84238b8 100644 --- a/src/utils/core/path.ts +++ b/src/utils/core/path.ts @@ -37,6 +37,7 @@ class PathManager { media: path.join(this.baseDir, 'public/files/media'), package: path.join(this.baseDir, 'package.json'), modules: path.join(this.baseDir, 'src/modules'), + uploads: path.join(this.baseDir, 'public/files/uploads'), }; return type ? mappings[type] : this.baseDir; @@ -65,6 +66,7 @@ class PathManager { this.get('userData'), this.get('media'), this.get('temp'), + this.get('uploads'), ]; pathsToInit.forEach((dirPath) => { @@ -86,7 +88,8 @@ type PathType = | 'files' | 'package' | 'media' - | 'modules'; + | 'modules' + | 'uploads'; const paths = PathManager.getInstance(); export default paths;