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 { // 确保 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 { 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 { 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 { 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 } }