216 lines
6.2 KiB
TypeScript
216 lines
6.2 KiB
TypeScript
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
|
||
}
|
||
|
||
/**
|
||
* R6修复:优化 S3 连接配置
|
||
*
|
||
* 原配置问题:
|
||
* - maxAttempts: 10 太激进,网络问题时可能长时间阻塞
|
||
* - 缺少请求级别的超时配置
|
||
*
|
||
* 优化后:
|
||
* - maxAttempts: 5(平衡重试次数和响应时间)
|
||
* - 保留 adaptive 重试模式(自动调整重试间隔)
|
||
* - 添加更详细的日志记录
|
||
*/
|
||
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秒连接超时
|
||
},
|
||
// R6修复:将重试次数从10次降低到5次
|
||
// 理由:10次重试 + adaptive 模式可能导致单次请求阻塞数分钟
|
||
// 5次重试足以应对临时网络抖动,同时避免长时间阻塞
|
||
maxAttempts: 5,
|
||
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)
|
||
let key = url.pathname.slice(1) // Remove leading slash
|
||
key = decodeURIComponent(key)
|
||
|
||
const bucket = process.env.S3_BUCKET!
|
||
if (key.startsWith(bucket + '/')) {
|
||
key = key.substring(bucket.length + 1)
|
||
}
|
||
|
||
const command = new DeleteObjectCommand({
|
||
Bucket: 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
|
||
}
|
||
}
|