195 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import { Readable } from 'stream'
/**
* 判断 URL 是否为 HTTP/HTTPS URL
*/
export function isHttpUrl(url: string): boolean {
if (!url) return false
return /^https?:\/\//.test(url)
}
/**
* 判断 URL 是否为 S3 URL
*/
export function isS3Url(url: string): boolean {
return isHttpUrl(url)
}
/**
* 判断 URL 是否为 S3 公网 URL
*/
export function isS3PublicUrl(url: string): boolean {
if (!url || !isHttpUrl(url)) return false
const publicDomain = process.env.S3_PUBLIC_DOMAIN || ''
if (!publicDomain) return false
try {
const urlObj = new URL(url)
const domainObj = new URL(publicDomain)
return urlObj.hostname === domainObj.hostname
} catch {
return false
}
}
// 清理S3_ENDPOINT,去除可能的注释和空白字符
function cleanS3Endpoint(endpoint: string | undefined): string {
if (!endpoint) return ''
// 去除注释(# 后面的内容)和前后空白
const cleaned = endpoint.split('#')[0].trim()
return cleaned
}
const s3Client = new S3Client({
endpoint: cleanS3Endpoint(process.env.S3_ENDPOINT),
region: process.env.S3_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
forcePathStyle: true, // MinIO requires path-style
requestHandler: {
// 增加超时时间,提高上传稳定性
requestTimeout: 300000, // 5分钟超时
connectionTimeout: 30000, // 30秒连接超时
},
maxAttempts: 10, // 失败重试10次,确保上传成功
retryMode: 'adaptive',
})
export async function uploadToS3(
file: Buffer,
fileName: string,
contentType: string
): Promise<string> {
// 确保 S3_FILE_PATH 以 / 结尾,fileName 不以 / 开头
const s3FilePath = (process.env.S3_FILE_PATH || '').trim()
const normalizedPath = s3FilePath && !s3FilePath.endsWith('/') ? `${s3FilePath}/` : s3FilePath
const normalizedFileName = fileName.startsWith('/') ? fileName.slice(1) : fileName
const key = `${normalizedPath}${normalizedFileName}`
console.log(`[S3] Uploading file: key=${key}, size=${file.length}, contentType=${contentType}`)
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: file,
ContentType: contentType,
ACL: 'public-read',
})
try {
await s3Client.send(command)
const url = `${process.env.S3_PUBLIC_DOMAIN}/${key}`
console.log(`[S3] Uploaded successfully: url=${url}`)
return url
} catch (error) {
// S3上传失败会自动重试(maxAttempts: 10),这里只记录日志
console.error('[S3] Upload error (after all retries):', error)
// 重新抛出错误,让上层处理
throw new Error('S3上传失败,请稍后重试')
}
}
export async function downloadFromS3(fileUrl: string): Promise<Buffer> {
try {
// Extract key from URL
const url = new URL(fileUrl)
let key = url.pathname.slice(1) // Remove leading slash
// URL decode the key to handle Chinese characters and special characters
key = decodeURIComponent(key)
// Remove bucket name from key if present (for MinIO path-style URLs)
// URL format: http://endpoint/bucket/key
const bucket = process.env.S3_BUCKET!
if (key.startsWith(bucket + '/')) {
key = key.substring(bucket.length + 1)
}
console.log(`[S3] Downloading file: bucket=${bucket}, key=${key}`)
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
})
const response = await s3Client.send(command)
// Convert stream to buffer
if (response.Body instanceof Readable) {
const chunks: Buffer[] = []
for await (const chunk of response.Body as Readable) {
chunks.push(Buffer.from(chunk))
}
const buffer = Buffer.concat(chunks)
console.log(`[S3] Downloaded successfully: size=${buffer.length}`)
return buffer
}
throw new Error('Invalid response body')
} catch (error) {
console.error('[S3] Download error:', error)
throw new Error('从S3下载文件失败')
}
}
export async function deleteFromS3(fileUrl: string): Promise<void> {
try {
// Extract key from URL
const url = new URL(fileUrl)
const key = url.pathname.slice(1) // Remove leading slash
const command = new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
})
await s3Client.send(command)
} catch (error) {
console.error('Error deleting from S3:', error)
throw error
}
}
/**
* 从 S3 获取对象流
*/
export async function getS3ObjectStream(key: string): Promise<{ body: Readable; contentLength?: number }> {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
})
const response = await s3Client.send(command)
return {
body: response.Body as Readable,
contentLength: response.ContentLength,
}
}
export { s3Client }
/**
* 测试 S3 连接
*/
export async function testS3Connection(): Promise<boolean> {
try {
// 尝试列出bucket(不需要实际列出,只是测试连接)
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: 'test-connection', // 不存在的key,只是测试连接
})
await s3Client.send(command).catch(() => {
// 预期会失败(key不存在),但能连接就说明S3服务正常
})
return true
} catch (error) {
console.error('[S3] Connection test failed:', error)
return false
}
}