- 创建统一的下载错误处理模块 (lib/download-errors.ts) - 实现13种错误类型分类和中文错误消息 - 后端统一使用 createDownloadError 创建标准化错误响应 - 前端统一使用 parseDownloadError 解析错误并显示 - 修复正式系统数据库Schema,添加产品全案相关字段和表 - 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段 - 创建 material_assets, api_keys, api_call_logs 表
144 lines
3.9 KiB
TypeScript
144 lines
3.9 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
|
||
}
|
||
}
|
||
|
||
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次,确保上传成功
|
||
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 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
|
||
}
|
||
}
|