217 lines
6.4 KiB
TypeScript
217 lines
6.4 KiB
TypeScript
import * as Minio from 'minio'
|
||
import { randomUUID } from 'crypto'
|
||
import { minioClient, BUCKET_NAME, initializeMinIO } from './minioClient'
|
||
|
||
export interface StoredFile {
|
||
id: string
|
||
filename: string
|
||
contentType: string
|
||
objectName: string // MinIO 对象名称
|
||
size: number
|
||
uploadTime: Date
|
||
}
|
||
|
||
export interface FileMetadata {
|
||
id: string
|
||
originalFilename: string
|
||
contentType: string
|
||
size: number
|
||
uploadTime: Date
|
||
objectName: string
|
||
}
|
||
|
||
// 生成对象名称,使用分层结构优化性能
|
||
function generateObjectName(type: 'screenshot' | 'version' | 'other', hostname?: string): 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')
|
||
const uuid = randomUUID()
|
||
|
||
if (type === 'screenshot' && hostname) {
|
||
return `screenshots/${year}/${month}/${day}/${hostname}/${uuid}`
|
||
} else if (type === 'version') {
|
||
return `versions/${year}/${month}/${uuid}`
|
||
} else {
|
||
return `files/${year}/${month}/${day}/${uuid}`
|
||
}
|
||
}
|
||
|
||
// 确保 MinIO 已初始化
|
||
let minioInitialized = false
|
||
export async function ensureMinIOReady(): Promise<void> {
|
||
if (!minioInitialized) {
|
||
const success = await initializeMinIO()
|
||
if (!success) {
|
||
throw new Error('MinIO 初始化失败')
|
||
}
|
||
minioInitialized = true
|
||
}
|
||
}
|
||
|
||
export async function storeFile(
|
||
buffer: Buffer,
|
||
filename: string,
|
||
contentType: string = 'application/octet-stream',
|
||
type: 'screenshot' | 'version' | 'other' = 'other',
|
||
hostname?: string
|
||
): Promise<StoredFile> {
|
||
await ensureMinIOReady()
|
||
|
||
const id = randomUUID()
|
||
const ext = filename.split('.').pop() || ''
|
||
const objectName = `${generateObjectName(type, hostname)}.${ext}`
|
||
|
||
// 设置元数据
|
||
const metadata = {
|
||
'Content-Type': contentType,
|
||
'X-Original-Filename': filename,
|
||
'X-File-ID': id,
|
||
'X-Upload-Time': new Date().toISOString(),
|
||
'X-File-Type': type,
|
||
...(hostname && { 'X-Hostname': hostname })
|
||
}
|
||
|
||
try {
|
||
// 上传到 MinIO
|
||
await minioClient.putObject(BUCKET_NAME, objectName, buffer, buffer.length, metadata)
|
||
|
||
console.log(`✅ 文件上传成功: ${objectName} (${buffer.length} bytes)`)
|
||
|
||
return {
|
||
id,
|
||
filename,
|
||
contentType,
|
||
objectName,
|
||
size: buffer.length,
|
||
uploadTime: new Date()
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 文件上传失败:', error)
|
||
throw new Error(`文件上传失败: ${error}`)
|
||
}
|
||
}
|
||
|
||
export async function getFile(fileIdOrObjectName: string): Promise<{ buffer: Buffer; contentType: string; filename: string } | null> {
|
||
try {
|
||
await ensureMinIOReady()
|
||
|
||
let objectName = fileIdOrObjectName
|
||
|
||
// 如果看起来是 fileId (UUID格式),我们需要从数据库查找 objectName
|
||
// 这里简化处理:如果包含 '/' 就认为是 objectName,否则当作 fileId
|
||
if (!fileIdOrObjectName.includes('/')) {
|
||
// 这是一个 fileId,需要从数据库查找对应的 objectName
|
||
// 注意:这里需要在 API 层面处理,因为不同的模型存储在不同表中
|
||
// 暂时直接使用传入的值,让 API 层传递正确的 objectName
|
||
console.log(`⚠️ 警告: getFile 收到的可能是 fileId 而不是 objectName: ${fileIdOrObjectName}`)
|
||
}
|
||
|
||
// 获取对象信息
|
||
const stat = await minioClient.statObject(BUCKET_NAME, objectName)
|
||
|
||
// 下载文件
|
||
const stream = await minioClient.getObject(BUCKET_NAME, objectName)
|
||
const chunks: Buffer[] = []
|
||
|
||
return new Promise((resolve, reject) => {
|
||
stream.on('data', (chunk) => chunks.push(chunk))
|
||
stream.on('end', () => {
|
||
const buffer = Buffer.concat(chunks)
|
||
resolve({
|
||
buffer,
|
||
contentType: stat.metaData?.['content-type'] || 'application/octet-stream',
|
||
filename: stat.metaData?.['x-original-filename'] || objectName.split('/').pop() || 'unknown'
|
||
})
|
||
})
|
||
stream.on('error', (error) => {
|
||
console.error('❌ 文件下载失败:', error)
|
||
reject(error)
|
||
})
|
||
})
|
||
} catch (error) {
|
||
console.error('❌ 获取文件失败:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 优化版本:直接通过 objectName 获取文件(用于已知对象名的情况)
|
||
export async function getFileByObjectName(objectName: string): Promise<{ buffer: Buffer; contentType: string; filename: string } | null> {
|
||
try {
|
||
await ensureMinIOReady()
|
||
|
||
// 获取对象信息
|
||
const stat = await minioClient.statObject(BUCKET_NAME, objectName)
|
||
|
||
// 下载文件
|
||
const stream = await minioClient.getObject(BUCKET_NAME, objectName)
|
||
const chunks: Buffer[] = []
|
||
|
||
return new Promise((resolve, reject) => {
|
||
stream.on('data', (chunk) => chunks.push(chunk))
|
||
stream.on('end', () => {
|
||
const buffer = Buffer.concat(chunks)
|
||
resolve({
|
||
buffer,
|
||
contentType: stat.metaData?.['content-type'] || 'application/octet-stream',
|
||
filename: stat.metaData?.['x-original-filename'] || objectName.split('/').pop() || 'unknown'
|
||
})
|
||
})
|
||
stream.on('error', (error) => {
|
||
console.error('❌ 文件下载失败:', error)
|
||
reject(error)
|
||
})
|
||
})
|
||
} catch (error) {
|
||
console.error('❌ 获取文件失败:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
export async function deleteFile(objectName: string): Promise<boolean> {
|
||
try {
|
||
await ensureMinIOReady()
|
||
|
||
await minioClient.removeObject(BUCKET_NAME, objectName)
|
||
console.log(`✅ 文件删除成功: ${objectName}`)
|
||
return true
|
||
} catch (error) {
|
||
console.error('❌ 文件删除失败:', error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 获取文件统计信息
|
||
export async function getStorageStats(): Promise<{
|
||
totalObjects: number;
|
||
totalSize: number;
|
||
bucketName: string;
|
||
}> {
|
||
try {
|
||
await ensureMinIOReady()
|
||
|
||
let totalObjects = 0
|
||
let totalSize = 0
|
||
|
||
const objectsStream = minioClient.listObjects(BUCKET_NAME, '', true)
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
objectsStream.on('data', (obj) => {
|
||
totalObjects++
|
||
totalSize += obj.size || 0
|
||
})
|
||
objectsStream.on('end', resolve)
|
||
objectsStream.on('error', reject)
|
||
})
|
||
|
||
return {
|
||
totalObjects,
|
||
totalSize,
|
||
bucketName: BUCKET_NAME
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 获取存储统计失败:', error)
|
||
return { totalObjects: 0, totalSize: 0, bucketName: BUCKET_NAME }
|
||
}
|
||
}
|