- 修复内置模板验证问题(builtin-video-material等) - 添加模板验证工具 lib/template-validator.ts - 创建模板系统优化需求文档(30个详细需求) - 优化上传API的模板验证逻辑 - 改进错误提示信息的清晰度 - 添加字段池和分类管理相关需求 - 完善自定义字段的创建、验证、存储逻辑 - 清理过期的 .specstory 和 .trae 文件
185 lines
4.8 KiB
TypeScript
185 lines
4.8 KiB
TypeScript
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<string> {
|
||
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<void> {
|
||
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,
|
||
}
|
||
}
|
||
|