-
Notifications
You must be signed in to change notification settings - Fork 67
feat: add MinIO file storage service with upload and download capabilities #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
2879ca1
feat: add MinIO file storage service with upload and download capabil…
sd0ric4 0935b6c
Merge remote-tracking branch 'origin/main' into s3
sd0ric4 6ad8f49
Refactor file upload functionality:
sd0ric4 957b49b
refactor: 优化文件服务实例管理,移除冗余代码并简化初始化逻辑
sd0ric4 a636f08
refactor: 移除冗余的文件保留时间计算函数,简化文件上传逻辑
sd0ric4 8462d3e
refactor: 移除冗余的文件服务初始化逻辑,简化文件服务类
sd0ric4 b5caa4f
refactor: 将 FileConfig 从接口改为类型定义,简化配置结构
sd0ric4 cb59128
refactor: 移除文件服务单例实例和便捷导出函数,简化文件服务接口
sd0ric4 be70544
Merge remote-tracking branch 'origin/main' into s3
sd0ric4 b73ee6d
feat: 引入 zod 进行文件元数据验证,更新上传文件逻辑
sd0ric4 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,10 @@ | ||
| PORT=3000 | ||
| AUTH_TOKEN=xxx | ||
| AUTH_TOKEN=xxx | ||
|
|
||
| # MinIO 文件存储配置 | ||
| MINIO_ENDPOINT=localhost | ||
| MINIO_PORT=9000 | ||
| MINIO_USE_SSL=false | ||
| MINIO_ACCESS_KEY=minioadmin | ||
| MINIO_SECRET_KEY=minioadmin | ||
| MINIO_BUCKET=files |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { z } from 'zod'; | ||
|
|
||
| export type FileConfig = { | ||
| maxFileSize: number; // 文件大小限制(字节) | ||
| retentionDays: number; // 保留天数(由 MinIO 生命周期策略自动管理) | ||
| endpoint: string; // MinIO endpoint | ||
| port?: number; // MinIO port | ||
| useSSL: boolean; // 是否使用SSL | ||
| accessKey: string; // MinIO access key | ||
| secretKey: string; // MinIO secret key | ||
| bucket: string; // 存储桶名称 | ||
| }; | ||
|
|
||
| // 默认配置(动态从环境变量读取) | ||
| export const defaultFileConfig: FileConfig = { | ||
| maxFileSize: process.env.MAX_FILE_SIZE ? parseInt(process.env.MAX_FILE_SIZE) : 20 * 1024 * 1024, // 默认 20MB | ||
| retentionDays: process.env.RETENTION_DAYS ? parseInt(process.env.RETENTION_DAYS) : 15, // 默认保留15天 | ||
| endpoint: process.env.MINIO_ENDPOINT || 'localhost', | ||
| port: process.env.MINIO_PORT ? parseInt(process.env.MINIO_PORT) : 9000, | ||
| useSSL: process.env.MINIO_USE_SSL === 'true', | ||
| accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin', | ||
| secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin', | ||
| bucket: process.env.MINIO_BUCKET || 'files' | ||
| }; | ||
|
|
||
| export const FileMetadataSchema = z.object({ | ||
| fileId: z.string(), | ||
| originalFilename: z.string(), | ||
| contentType: z.string(), | ||
| size: z.number(), | ||
| uploadTime: z.date(), | ||
| accessUrl: z.string() | ||
| }); | ||
|
|
||
| export type FileMetadata = z.infer<typeof FileMetadataSchema>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,236 @@ | ||
| import * as Minio from 'minio'; | ||
| import { randomBytes } from 'crypto'; | ||
| import { defaultFileConfig, type FileConfig, type FileMetadata } from './config'; | ||
| import { addLog } from '@/utils/log'; | ||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
| import { z } from 'zod'; | ||
|
|
||
| // ================================ | ||
| // 1. 类型定义和验证 | ||
| // ================================ | ||
|
|
||
| export const FileInputSchema = z | ||
| .object({ | ||
| url: z.string().url('Invalid URL format').optional(), | ||
| path: z.string().min(1, 'File path cannot be empty').optional(), | ||
| data: z.string().min(1, 'Base64 data cannot be empty').optional(), | ||
| buffer: z.instanceof(Buffer, { message: 'Buffer is required' }).optional(), | ||
| filename: z.string().optional() | ||
| }) | ||
| .refine( | ||
| (data) => { | ||
| const inputMethods = [data.url, data.path, data.data, data.buffer].filter(Boolean); | ||
| return inputMethods.length === 1 && (!(data.data || data.buffer) || data.filename); | ||
| }, | ||
| { | ||
| message: 'Provide exactly one input method. Filename required for base64/buffer inputs.' | ||
| } | ||
| ); | ||
|
|
||
| export type FileInput = z.infer<typeof FileInputSchema>; | ||
|
|
||
| // ================================ | ||
| // 2. 文件服务主类 | ||
| // ================================ | ||
|
|
||
| export class FileService { | ||
| private minioClient: Minio.Client; | ||
| private config: FileConfig; | ||
|
|
||
| // ================================ | ||
| // 2.1 初始化相关 | ||
| // ================================ | ||
|
|
||
| constructor(config?: Partial<FileConfig>) { | ||
| this.config = { ...defaultFileConfig, ...config }; | ||
|
|
||
| this.minioClient = new Minio.Client({ | ||
| endPoint: this.config.endpoint, | ||
| port: this.config.port, | ||
| useSSL: this.config.useSSL, | ||
| accessKey: this.config.accessKey, | ||
| secretKey: this.config.secretKey | ||
| }); | ||
| } | ||
|
|
||
| // ================================ | ||
| // 2.2 连接和存储桶管理 | ||
| // ================================ | ||
|
|
||
| // ================================ | ||
| // 2.3 文件处理工具方法 | ||
| // ================================ | ||
|
|
||
| private generateFileId(): string { | ||
| return randomBytes(16).toString('hex'); | ||
| } | ||
|
|
||
| private generateAccessUrl(fileId: string, filename: string): string { | ||
| const protocol = this.config.useSSL ? 'https' : 'http'; | ||
| const port = | ||
| this.config.port && this.config.port !== (this.config.useSSL ? 443 : 80) | ||
| ? `:${this.config.port}` | ||
| : ''; | ||
| return `${protocol}://${this.config.endpoint}${port}/${this.config.bucket}/${fileId}/${encodeURIComponent(filename)}`; | ||
| } | ||
|
|
||
| // 根据文件扩展名推断真实的 Content-Type(用于返回给用户) | ||
| private inferContentType(filename: string): string { | ||
| const ext = path.extname(filename).toLowerCase(); | ||
| const mimeMap: Record<string, string> = { | ||
| '.jpg': 'image/jpeg', | ||
| '.jpeg': 'image/jpeg', | ||
| '.png': 'image/png', | ||
| '.gif': 'image/gif', | ||
| '.webp': 'image/webp', | ||
| '.svg': 'image/svg+xml', | ||
| '.pdf': 'application/pdf', | ||
| '.txt': 'text/plain', | ||
| '.json': 'application/json', | ||
| '.csv': 'text/csv', | ||
| '.zip': 'application/zip', | ||
| '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', | ||
| '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | ||
| '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', | ||
| '.doc': 'application/msword', | ||
| '.xls': 'application/vnd.ms-excel', | ||
| '.ppt': 'application/vnd.ms-powerpoint' | ||
| }; | ||
|
|
||
| return mimeMap[ext] || 'application/octet-stream'; | ||
| } | ||
|
|
||
| // ================================ | ||
| // 2.4 核心上传方法 | ||
| // ================================ | ||
|
|
||
| async uploadFile(fileBuffer: Buffer, originalFilename: string): Promise<FileMetadata> { | ||
| if (fileBuffer.length > this.config.maxFileSize) { | ||
| return Promise.reject( | ||
| new Error(`File size ${fileBuffer.length} exceeds limit ${this.config.maxFileSize}`) | ||
| ); | ||
| } | ||
|
|
||
| const fileId = this.generateFileId(); | ||
| const objectName = `${fileId}/${originalFilename}`; | ||
| const uploadTime = new Date(); | ||
|
|
||
| // 推断文件的真实 Content-Type(用于返回给用户) | ||
| const realContentType = this.inferContentType(originalFilename); | ||
|
|
||
| try { | ||
| await this.minioClient.putObject( | ||
| this.config.bucket, | ||
| objectName, | ||
| fileBuffer, | ||
| fileBuffer.length, | ||
| { | ||
| 'Content-Type': 'application/octet-stream', // 强制下载,不预览 | ||
| 'Content-Disposition': `attachment; filename="${encodeURIComponent(originalFilename)}"`, | ||
| 'x-amz-meta-original-filename': encodeURIComponent(originalFilename), | ||
| 'x-amz-meta-upload-time': uploadTime.toISOString() | ||
| } | ||
| ); | ||
|
|
||
| const metadata: FileMetadata = { | ||
| fileId, | ||
| originalFilename, | ||
| contentType: realContentType, // 返回真实的 Content-Type | ||
| size: fileBuffer.length, | ||
| uploadTime, | ||
| accessUrl: this.generateAccessUrl(fileId, originalFilename) | ||
| }; | ||
|
|
||
| return metadata; | ||
| } catch (error) { | ||
| addLog.error('Failed to upload file:', error); | ||
| return Promise.reject(error); | ||
| } | ||
| } | ||
|
|
||
| async uploadFileAdvanced(input: FileInput): Promise<FileMetadata> { | ||
| const validatedInput = FileInputSchema.parse(input); | ||
|
|
||
| try { | ||
| const { buffer, filename } = await (async () => { | ||
| if (validatedInput.url) return await this.handleNetworkFile(validatedInput); | ||
| if (validatedInput.path) return await this.handleLocalFile(validatedInput); | ||
| if (validatedInput.data) return this.handleBase64File(validatedInput); | ||
| if (validatedInput.buffer) return this.handleBufferFile(validatedInput); | ||
| return Promise.reject(new Error('No valid input method provided')); | ||
| })(); | ||
|
|
||
| const metadata = await this.uploadFile(buffer, filename); | ||
| return metadata; | ||
| } catch (error) { | ||
| if (error instanceof z.ZodError) { | ||
| return Promise.reject( | ||
| new Error(`Invalid input: ${error.errors.map((e) => e.message).join(', ')}`) | ||
| ); | ||
| } | ||
| addLog.error(`Upload failed:`, error); | ||
| return Promise.reject(error); | ||
| } | ||
| } | ||
|
|
||
| // ================================ | ||
| // 2.5 多种输入方式文件处理 | ||
| // ================================ | ||
|
|
||
| private async handleNetworkFile(input: FileInput): Promise<{ buffer: Buffer; filename: string }> { | ||
| const response = await fetch(input.url!); | ||
| if (!response.ok) | ||
| return Promise.reject( | ||
| new Error(`Download failed: ${response.status} ${response.statusText}`) | ||
| ); | ||
|
|
||
| const buffer = Buffer.from(await response.arrayBuffer()); | ||
| const filename = (() => { | ||
| if (input.filename) return input.filename; | ||
|
|
||
| const urlFilename = path.basename(new URL(input.url!).pathname) || 'downloaded_file'; | ||
|
|
||
| // 如果文件名没有扩展名,使用默认扩展名 | ||
| if (!path.extname(urlFilename)) { | ||
| return urlFilename + '.bin'; // 默认扩展名 | ||
| } | ||
|
|
||
| return urlFilename; | ||
| })(); | ||
|
|
||
| return { buffer, filename }; | ||
| } | ||
|
|
||
| private async handleLocalFile(input: FileInput): Promise<{ buffer: Buffer; filename: string }> { | ||
| if (!fs.existsSync(input.path!)) | ||
| return Promise.reject(new Error(`File not found: ${input.path}`)); | ||
|
|
||
| const buffer = await fs.promises.readFile(input.path!); | ||
| const filename = input.filename || path.basename(input.path!); | ||
|
|
||
| return { buffer, filename }; | ||
| } | ||
|
|
||
| private handleBase64File(input: FileInput): { | ||
| buffer: Buffer; | ||
| filename: string; | ||
| } { | ||
| const base64Data = (() => { | ||
| const data = input.data!; | ||
| return data.includes(',') ? data.split(',')[1] : data; // Remove data URL prefix if present | ||
| })(); | ||
|
|
||
| return { | ||
| buffer: Buffer.from(base64Data, 'base64'), | ||
| filename: input.filename! | ||
| }; | ||
| } | ||
|
|
||
| private handleBufferFile(input: FileInput): { | ||
| buffer: Buffer; | ||
| filename: string; | ||
| } { | ||
| return { buffer: input.buffer!, filename: input.filename! }; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
这个会导致覆盖原来的 content type 么
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
好像有 attachment 就好了吧