chenpei 4712175cb0 feat(素材链路): 发布白底图双轨与AI生图回传能力
- 完成白底图 WebP 双轨接口、上传链路与预览缓存改造
- 新增 AI 生图平台成品回传接口、任务状态与表单规则
- 补齐 AI 生成标签、任务详情补全、相关测试与对接文档
2026-03-25 10:28:02 +08:00

216 lines
6.2 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
}
}
// 清理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
}
}