Ver Fonte

添加文件上传功能,包括ZIP、图片和文档的上传,更新环境配置以支持OSS,新增文件服务和控制器,修改路由以集成新功能。

wui há 7 meses atrás
pai
commit
f54450b3d5

+ 8 - 1
.env

@@ -11,4 +11,11 @@ DB_DATABASE=tweb_test
 
 # JWT
 JWT_SECRET='G5HXsfhW!gKr&4W8'
-JWT_EXPIRES_IN=7d
+JWT_EXPIRES_IN=7d
+
+# OSS
+OSS_KEY=LTAI5tEwZWpR1U3ZpSJ4RMJE
+OSS_SECRET=YTAgTr8lWX4IrtDBM2Efpqa0iD5FfE
+OSS_BUCKET=afjp282x4b
+OSS_REGION=oss-ap-southeast-3
+OSS_ENDPOINT=https://oss-ap-southeast-3.aliyuncs.com

Diff do ficheiro suprimidas por serem muito extensas
+ 794 - 1
package-lock.json


+ 3 - 0
package.json

@@ -13,8 +13,11 @@
     "@fastify/cors": "^11.0.1",
     "@fastify/env": "^5.0.2",
     "@fastify/jwt": "^9.1.0",
+    "@fastify/multipart": "^9.0.3",
     "@fastify/swagger": "^9.4.2",
     "@fastify/swagger-ui": "^5.2.2",
+    "@types/ali-oss": "^6.16.11",
+    "ali-oss": "^6.23.0",
     "bcryptjs": "^3.0.2",
     "class-transformer": "^0.5.1",
     "class-validator": "^0.14.1",

+ 9 - 0
src/app.ts

@@ -4,11 +4,13 @@ import cors from '@fastify/cors'
 import jwt from '@fastify/jwt'
 import swagger from '@fastify/swagger'
 import swaggerUi from '@fastify/swagger-ui'
+import multipart from '@fastify/multipart'
 import fastifyEnv, { FastifyEnvOptions } from '@fastify/env'
 import { schema } from './config/env'
 import { createDataSource } from './config/database'
 import userRoutes from './routes/user.routes'
 import recordsRoutes from './routes/records.routes'
+import fileRoutes from './routes/file.routes'
 import { config } from 'dotenv'
 
 config()
@@ -47,6 +49,12 @@ const start = async () => {
     }
   })
 
+  app.register(multipart, {
+    limits: {
+      fileSize: 200 * 1024 * 1024 // 200MB
+    }
+  })
+
   app.register(swagger, {
     swagger: {
       info: {
@@ -67,6 +75,7 @@ const start = async () => {
 
   app.register(userRoutes, { prefix: '/api/users' })
   app.register(recordsRoutes, { prefix: '/api/records' })
+  app.register(fileRoutes, { prefix: '/api/files' })
 
   const dataSource = createDataSource(app)
   await dataSource.initialize()

+ 21 - 1
src/config/env.ts

@@ -10,7 +10,12 @@ export const schema = {
     'DB_PASSWORD',
     'DB_DATABASE',
     'JWT_SECRET',
-    'JWT_EXPIRES_IN'
+    'JWT_EXPIRES_IN',
+    'OSS_KEY',
+    'OSS_SECRET',
+    'OSS_BUCKET',
+    'OSS_REGION',
+    'OSS_ENDPOINT'
   ],
   properties: {
     PORT: {
@@ -43,6 +48,21 @@ export const schema = {
     },
     JWT_EXPIRES_IN: {
       type: 'string'
+    },
+    OSS_KEY: {
+      type: 'string'
+    },
+    OSS_SECRET: {
+      type: 'string'
+    },
+    OSS_BUCKET: {
+      type: 'string'
+    },
+    OSS_REGION: {
+      type: 'string'
+    },
+    OSS_ENDPOINT: {
+      type: 'string'
     }
   }
 }

+ 199 - 0
src/controllers/file.controller.ts

@@ -0,0 +1,199 @@
+import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
+import { FileService } from '../services/file.service'
+
+export class FileController {
+  private fileService: FileService
+
+  constructor(app: FastifyInstance) {
+    this.fileService = new FileService(app)
+  }
+
+  /**
+   * 上传ZIP文件
+   */
+  async uploadZip(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const data = await request.file()
+      
+      if (!data) {
+        return reply.code(400).send({ message: '请选择要上传的ZIP文件' })
+      }
+
+      const buffer = await data.toBuffer()
+      const filename = data.filename
+
+      // 验证文件类型
+      if (!filename.toLowerCase().endsWith('.zip')) {
+        return reply.code(400).send({ message: '只支持ZIP格式的压缩包' })
+      }
+
+      const result = await this.fileService.uploadZip(buffer, filename, {
+        folder: 'tweb',
+        maxSize: 100 * 1024 * 1024 // 100MB
+      })
+
+      return reply.send({
+        message: 'ZIP文件上传成功',
+        data: {
+          ...result,
+          dateFolder: new Date().toISOString().split('T')[0] // YYYY-MM-DD格式
+        }
+      })
+    } catch (error) {
+      return reply.code(500).send({
+        message: 'ZIP文件上传失败',
+        error: error instanceof Error ? error.message : '未知错误'
+      })
+    }
+  }
+
+  /**
+   * 上传文件
+   */
+  async uploadFile(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const data = await request.file()
+      
+      if (!data) {
+        return reply.code(400).send({ message: '请选择要上传的文件' })
+      }
+
+      const buffer = await data.toBuffer()
+      const filename = data.filename
+      const mimeType = data.mimetype
+
+      const result = await this.fileService.uploadFile(buffer, filename, mimeType, {
+        folder: 'tweb',
+        maxSize: 10 * 1024 * 1024 // 10MB
+      })
+
+      return reply.send({
+        message: '文件上传成功',
+        data: {
+          ...result,
+          dateFolder: new Date().toISOString().split('T')[0] // YYYY-MM-DD格式
+        }
+      })
+    } catch (error) {
+      return reply.code(500).send({
+        message: '文件上传失败',
+        error: error instanceof Error ? error.message : '未知错误'
+      })
+    }
+  }
+
+  /**
+   * 上传图片
+   */
+  async uploadImage(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const data = await request.file()
+      
+      if (!data) {
+        return reply.code(400).send({ message: '请选择要上传的图片' })
+      }
+
+      const buffer = await data.toBuffer()
+      const filename = data.filename
+
+      const result = await this.fileService.uploadImage(buffer, filename, {
+        folder: 'tweb',
+        maxSize: 5 * 1024 * 1024 // 5MB
+      })
+
+      return reply.send({
+        message: '图片上传成功',
+        data: {
+          ...result,
+          dateFolder: new Date().toISOString().split('T')[0] // YYYY-MM-DD格式
+        }
+      })
+    } catch (error) {
+      return reply.code(500).send({
+        message: '图片上传失败',
+        error: error instanceof Error ? error.message : '未知错误'
+      })
+    }
+  }
+
+  /**
+   * 上传文档
+   */
+  async uploadDocument(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const data = await request.file()
+      
+      if (!data) {
+        return reply.code(400).send({ message: '请选择要上传的文档' })
+      }
+
+      const buffer = await data.toBuffer()
+      const filename = data.filename
+
+      const result = await this.fileService.uploadDocument(buffer, filename, {
+        folder: 'tweb',
+        maxSize: 50 * 1024 * 1024 // 50MB
+      })
+
+      return reply.send({
+        message: '文档上传成功',
+        data: {
+          ...result,
+          dateFolder: new Date().toISOString().split('T')[0] // YYYY-MM-DD格式
+        }
+      })
+    } catch (error) {
+      return reply.code(500).send({
+        message: '文档上传失败',
+        error: error instanceof Error ? error.message : '未知错误'
+      })
+    }
+  }
+
+  /**
+   * 删除文件
+   */
+  async deleteFile(request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) {
+    try {
+      const { key } = request.params
+
+      const success = await this.fileService.deleteFile(key)
+
+      if (success) {
+        return reply.send({
+          message: '文件删除成功'
+        })
+      } else {
+        return reply.code(404).send({
+          message: '文件不存在或删除失败'
+        })
+      }
+    } catch (error) {
+      return reply.code(500).send({
+        message: '文件删除失败',
+        error: error instanceof Error ? error.message : '未知错误'
+      })
+    }
+  }
+
+  /**
+   * 获取文件访问URL
+   */
+  async getFileUrl(request: FastifyRequest<{ Querystring: { key: string; expires?: number } }>, reply: FastifyReply) {
+    try {
+      const { key, expires = 3600 } = request.query
+
+      const url = await this.fileService.getSignedUrl(key, expires)
+
+      return reply.send({
+        url,
+        expires
+      })
+    } catch (error) {
+      return reply.code(500).send({
+        message: '获取文件URL失败',
+        error: error instanceof Error ? error.message : '未知错误'
+      })
+    }
+  }
+} 

+ 49 - 0
src/routes/file.routes.ts

@@ -0,0 +1,49 @@
+import { FastifyInstance } from 'fastify'
+import { FileController } from '../controllers/file.controller'
+import { authenticate } from '../middlewares/auth.middleware'
+
+export default async function fileRoutes(fastify: FastifyInstance) {
+  const fileController = new FileController(fastify)
+
+  // 上传ZIP文件
+  fastify.post(
+    '/upload/zip',
+    { onRequest: [authenticate] },
+    fileController.uploadZip.bind(fileController)
+  )
+
+  // 上传文件
+  fastify.post(
+    '/upload',
+    { onRequest: [authenticate] },
+    fileController.uploadFile.bind(fileController)
+  )
+
+  // 上传图片
+  fastify.post(
+    '/upload/image',
+    { onRequest: [authenticate] },
+    fileController.uploadImage.bind(fileController)
+  )
+
+  // 上传文档
+  fastify.post(
+    '/upload/document',
+    { onRequest: [authenticate] },
+    fileController.uploadDocument.bind(fileController)
+  )
+
+  // 删除文件
+  fastify.delete<{ Params: { key: string } }>(
+    '/:key',
+    { onRequest: [authenticate] },
+    fileController.deleteFile.bind(fileController)
+  )
+
+  // 获取文件访问URL
+  fastify.get<{ Querystring: { key: string; expires?: number } }>(
+    '/url',
+    { onRequest: [authenticate] },
+    fileController.getFileUrl.bind(fileController)
+  )
+} 

+ 4 - 4
src/routes/records.routes.ts

@@ -29,15 +29,15 @@ export default async function recordsRoutes(fastify: FastifyInstance) {
   )
 
   // 更新记录
-  fastify.put<{ Body: UpdateRecordsBody }>(
-    '/',
+  fastify.post<{ Body: UpdateRecordsBody }>(
+    '/update',
     { onRequest: [authenticate] },
     recordsController.update.bind(recordsController)
   )
 
   // 删除记录
-  fastify.delete<{ Params: { id: string } }>(
-    '/:id',
+  fastify.get<{ Params: { id: string } }>(
+    '/delete/:id',
     { onRequest: [authenticate] },
     recordsController.delete.bind(recordsController)
   )

+ 299 - 0
src/services/file.service.ts

@@ -0,0 +1,299 @@
+import OSS from 'ali-oss'
+import { FastifyInstance } from 'fastify'
+import { randomUUID } from 'crypto'
+import path from 'path'
+
+export interface UploadResult {
+  url: string
+  key: string
+  size: number
+  mimeType: string
+}
+
+export interface FileUploadOptions {
+  folder?: string
+  filename?: string
+  allowedTypes?: string[]
+  maxSize?: number // 单位:字节
+  useSignedUrl?: boolean // 是否使用签名URL,默认false(直接URL)
+}
+
+export class FileService {
+  private ossClient: OSS
+  private bucket: string
+
+  constructor(app: FastifyInstance) {
+    const config = app.config
+    
+    this.bucket = config.OSS_BUCKET
+    this.ossClient = new OSS({
+      accessKeyId: config.OSS_KEY,
+      accessKeySecret: config.OSS_SECRET,
+      bucket: config.OSS_BUCKET,
+      region: config.OSS_REGION,
+      endpoint: config.OSS_ENDPOINT,
+      secure: true // 使用HTTPS
+    })
+  }
+
+  /**
+   * 获取当前日期文件夹名称
+   * @returns 日期文件夹名称 (格式: YYYY-MM-DD)
+   */
+  private getDateFolder(): string {
+    const now = new Date()
+    const year = now.getFullYear()
+    const month = String(now.getMonth() + 1).padStart(2, '0')
+    const day = String(now.getDate()).padStart(2, '0')
+    return `${year}-${month}-${day}`
+  }
+
+  /**
+   * 上传ZIP文件
+   * @param buffer ZIP文件buffer
+   * @param originalName 原始文件名
+   * @param options 上传选项
+   * @returns 上传结果
+   */
+  async uploadZip(
+    buffer: Buffer,
+    originalName: string,
+    options: FileUploadOptions = {}
+  ): Promise<UploadResult> {
+    // 验证文件类型
+    if (!originalName.toLowerCase().endsWith('.zip')) {
+      throw new Error('只支持ZIP格式的压缩包')
+    }
+
+    // 验证文件大小
+    if (options.maxSize && buffer.length > options.maxSize) {
+      throw new Error(`文件大小超过限制: ${buffer.length} > ${options.maxSize}`)
+    }
+
+    // 生成文件名
+    const extension = path.extname(originalName)
+    const filename = options.filename || `${randomUUID()}${extension}`
+    
+    // 获取当前日期文件夹
+    const dateFolder = this.getDateFolder()
+    const baseFolder = options.folder || 'tweb'
+    const targetFolder = `${baseFolder}/${dateFolder}`
+    
+    // 构建文件路径
+    const key = `${targetFolder}/${filename}`
+
+    try {
+      // 上传到OSS
+      const result = await this.ossClient.put(key, buffer, {
+        mime: 'application/zip',
+        headers: {
+          'Cache-Control': 'max-age=31536000' // 缓存1年
+        }
+      })
+
+      return {
+        url: result.url,
+        key: key,
+        size: buffer.length,
+        mimeType: 'application/zip'
+      }
+    } catch (error) {
+      throw new Error(`ZIP文件上传失败: ${error instanceof Error ? error.message : '未知错误'}`)
+    }
+  }
+
+  /**
+   * 上传文件到阿里云OSS
+   * @param buffer 文件buffer
+   * @param originalName 原始文件名
+   * @param mimeType 文件MIME类型
+   * @param options 上传选项
+   * @returns 上传结果
+   */
+  async uploadFile(
+    buffer: Buffer,
+    originalName: string,
+    mimeType: string,
+    options: FileUploadOptions = {}
+  ): Promise<UploadResult> {
+    // 验证文件类型
+    if (options.allowedTypes && !options.allowedTypes.includes(mimeType)) {
+      throw new Error(`不支持的文件类型: ${mimeType}`)
+    }
+
+    // 验证文件大小
+    if (options.maxSize && buffer.length > options.maxSize) {
+      throw new Error(`文件大小超过限制: ${buffer.length} > ${options.maxSize}`)
+    }
+
+    // 生成文件名
+    const extension = path.extname(originalName)
+    const filename = options.filename || `${randomUUID()}${extension}`
+    
+    // 获取当前日期文件夹
+    const dateFolder = this.getDateFolder()
+    const baseFolder = options.folder || 'tweb'
+    const targetFolder = `${baseFolder}/${dateFolder}`
+    
+    // 构建文件路径
+    const key = `${targetFolder}/${filename}`
+
+    try {
+      // 上传到OSS
+      const result = await this.ossClient.put(key, buffer, {
+        mime: mimeType,
+        headers: {
+          'Cache-Control': 'max-age=31536000' // 缓存1年
+        }
+      })
+
+      return {
+        url: result.url,
+        key: key,
+        size: buffer.length,
+        mimeType: mimeType
+      }
+    } catch (error) {
+      throw new Error(`文件上传失败: ${error instanceof Error ? error.message : '未知错误'}`)
+    }
+  }
+
+  /**
+   * 上传图片文件
+   * @param buffer 图片buffer
+   * @param originalName 原始文件名
+   * @param options 上传选项
+   * @returns 上传结果
+   */
+  async uploadImage(
+    buffer: Buffer,
+    originalName: string,
+    options: Omit<FileUploadOptions, 'allowedTypes'> = {}
+  ): Promise<UploadResult> {
+    const imageTypes = [
+      'image/jpeg',
+      'image/jpg',
+      'image/png',
+      'image/gif',
+      'image/webp',
+      'image/svg+xml'
+    ]
+
+    return this.uploadFile(buffer, originalName, 'image/jpeg', {
+      ...options,
+      folder: options.folder || 'tweb',
+      allowedTypes: imageTypes,
+      maxSize: options.maxSize || 10 * 1024 * 1024 // 默认10MB
+    })
+  }
+
+  /**
+   * 上传文档文件
+   * @param buffer 文档buffer
+   * @param originalName 原始文件名
+   * @param options 上传选项
+   * @returns 上传结果
+   */
+  async uploadDocument(
+    buffer: Buffer,
+    originalName: string,
+    options: Omit<FileUploadOptions, 'allowedTypes'> = {}
+  ): Promise<UploadResult> {
+    const documentTypes = [
+      'application/pdf',
+      'application/msword',
+      'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+      'application/vnd.ms-excel',
+      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+      'text/plain'
+    ]
+
+    return this.uploadFile(buffer, originalName, 'application/pdf', {
+      ...options,
+      folder: options.folder || 'tweb',
+      allowedTypes: documentTypes,
+      maxSize: options.maxSize || 50 * 1024 * 1024 // 默认50MB
+    })
+  }
+
+  /**
+   * 删除文件
+   * @param key 文件key
+   * @returns 删除结果
+   */
+  async deleteFile(key: string): Promise<boolean> {
+    try {
+      await this.ossClient.delete(key)
+      return true
+    } catch (error) {
+      throw new Error(`文件删除失败: ${error instanceof Error ? error.message : '未知错误'}`)
+    }
+  }
+
+  /**
+   * 获取文件访问URL
+   * @param key 文件key
+   * @param expires 过期时间(秒),默认24小时
+   * @returns 签名URL
+   */
+  async getSignedUrl(key: string, expires: number = 24 * 60 * 60): Promise<string> {
+    try {
+      const url = await this.ossClient.signatureUrl(key, {
+        expires: expires
+      })
+      return url
+    } catch (error) {
+      throw new Error(`获取签名URL失败: ${error instanceof Error ? error.message : '未知错误'}`)
+    }
+  }
+
+  /**
+   * 检查文件是否存在
+   * @param key 文件key
+   * @returns 是否存在
+   */
+  async fileExists(key: string): Promise<boolean> {
+    try {
+      await this.ossClient.head(key)
+      return true
+    } catch (error) {
+      return false
+    }
+  }
+
+  /**
+   * 获取文件信息
+   * @param key 文件key
+   * @returns 文件信息
+   */
+  async getFileInfo(key: string): Promise<any> {
+    try {
+      return await this.ossClient.head(key)
+    } catch (error) {
+      throw new Error(`获取文件信息失败: ${error instanceof Error ? error.message : '未知错误'}`)
+    }
+  }
+
+  /**
+   * 批量删除文件
+   * @param keys 文件key数组
+   * @returns 删除结果
+   */
+  async deleteFiles(keys: string[]): Promise<{ success: string[], failed: string[] }> {
+    const result = {
+      success: [] as string[],
+      failed: [] as string[]
+    }
+
+    for (const key of keys) {
+      try {
+        await this.ossClient.delete(key)
+        result.success.push(key)
+      } catch (error) {
+        result.failed.push(key)
+      }
+    }
+
+    return result
+  }
+} 

+ 15 - 0
src/types/fastify.d.ts

@@ -15,7 +15,22 @@ declare module 'fastify' {
       DB_USERNAME: string
       DB_PASSWORD: string
       DB_DATABASE: string
+      
+      // OSS配置
+      OSS_KEY: string
+      OSS_SECRET: string
+      OSS_BUCKET: string
+      OSS_REGION: string
+      OSS_ENDPOINT: string
     }
     dataSource: DataSource
   }
+
+  interface FastifyRequest {
+    file(): Promise<{
+      filename: string
+      mimetype: string
+      toBuffer(): Promise<Buffer>
+    } | null>
+  }
 }

Diff do ficheiro suprimidas por serem muito extensas
+ 358 - 82
yarn.lock


Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff