import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' const s3Client = new S3Client({ endpoint: 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次,确保上传成功 }) const normalizePublicDomain = () => { const raw = (process.env.S3_PUBLIC_DOMAIN || '').trim() if (!raw) return '' try { const url = new URL(raw) const host = url.hostname if (host === '127.0.0.1' || host === 'localhost') { // 尝试使用 SERVER_IP 或 APP_PUBLIC_URL 的主机名替换,避免浏览器访问本机导致 404 const fallbackHost = process.env.SERVER_IP || (() => { const appUrl = process.env.APP_PUBLIC_URL || process.env.NEXTAUTH_URL || '' if (!appUrl) return '' try { return new URL(appUrl).hostname } catch { return '' } })() if (fallbackHost) { url.hostname = fallbackHost } } return url.toString().replace(/\/$/, '') } catch { return raw.replace(/\/$/, '') } } const buildKeyPrefix = () => { const sanitize = (value?: string | null) => { if (!value) return null return value.replace(/^\/+|\/+$/g, '') } const segments = [ sanitize(process.env.S3_ENV_PREFIX), sanitize(process.env.S3_FILE_PATH), ].filter(Boolean) if (!segments.length) return '' return `${segments.join('/')}/` } const sanitizeKey = (value: string) => value.replace(/^\/+/, '') export function isS3Url(value?: string | null) { if (!value) return false return /^https?:\/\//.test(value) } export function extractS3Key(fileUrlOrKey: string): string { if (!fileUrlOrKey) { throw new Error('Invalid S3 path') } if (!isS3Url(fileUrlOrKey)) { return sanitizeKey(fileUrlOrKey) } const normalizedDomain = normalizePublicDomain() if (normalizedDomain && fileUrlOrKey.startsWith(normalizedDomain)) { return sanitizeKey(fileUrlOrKey.slice(normalizedDomain.length)) } try { const url = new URL(fileUrlOrKey) return sanitizeKey(url.pathname) } catch { return sanitizeKey(fileUrlOrKey) } } export async function uploadToS3( file: Buffer, fileName: string, contentType: string ): Promise { const keyPrefix = buildKeyPrefix() const key = `${keyPrefix}${fileName}` const command = new PutObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key, Body: file, ContentType: contentType, ACL: 'public-read', }) try { await s3Client.send(command) const publicDomain = normalizePublicDomain() return publicDomain ? `${publicDomain}/${key}` : key } catch (error) { // S3上传失败会自动重试(maxAttempts: 10),这里只记录日志 console.error('[S3] Upload error (after all retries):', error) // 重新抛出错误,让上层处理 throw new Error('S3上传失败,请稍后重试') } } export async function deleteFromS3(fileUrl: string): Promise { try { const key = extractS3Key(fileUrl) 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 } } export { s3Client } type SignedUrlOptions = { expiresIn?: number responseContentDisposition?: string responseContentType?: string } export async function createPresignedDownloadUrl( fileUrlOrKey: string, options: SignedUrlOptions = {} ) { const key = extractS3Key(fileUrlOrKey) const command = new GetObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key, ResponseContentDisposition: options.responseContentDisposition, ResponseContentType: options.responseContentType, }) const expiresIn = options.expiresIn ?? 60 * 10 // default 10 minutes return getSignedUrl(s3Client, command, { expiresIn }) } export async function getS3ObjectStream( fileUrlOrKey: string, rangeHeader?: string | null ) { const key = extractS3Key(fileUrlOrKey) const command = new GetObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key, Range: rangeHeader || undefined, }) const response = await s3Client.send(command) return { body: response.Body, contentLength: response.ContentLength, contentRange: response.ContentRange, contentType: response.ContentType, acceptRanges: response.AcceptRanges, } }