chenpei ea9076df45 feat: 实现统一的下载错误处理系统并修复产品全案数据库Schema
- 创建统一的下载错误处理模块 (lib/download-errors.ts)
- 实现13种错误类型分类和中文错误消息
- 后端统一使用 createDownloadError 创建标准化错误响应
- 前端统一使用 parseDownloadError 解析错误并显示
- 修复正式系统数据库Schema,添加产品全案相关字段和表
- 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段
- 创建 material_assets, api_keys, api_call_logs 表
2026-01-05 17:52:42 +08:00

144 lines
3.9 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
}
}
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
}
}