|
|
@@ -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
|
|
|
+ }
|
|
|
+}
|