121 lines
3.3 KiB
TypeScript
121 lines
3.3 KiB
TypeScript
import { unlink } from 'fs/promises'
|
|
import { isAbsolute } from 'path'
|
|
import { deleteFromS3, isS3Url } from '@/lib/s3'
|
|
|
|
export interface MaterialStorageLike {
|
|
zipFilePath?: string | null
|
|
previewUrl?: string | null
|
|
previewUrls?: string | null
|
|
displayImages?: string | null
|
|
videoSourceUrl?: string | null
|
|
videoPreview540Url?: string | null
|
|
videoPreviewClipUrl?: string | null
|
|
videoCoverUrl?: string | null
|
|
}
|
|
|
|
export interface MaterialStorageCleanupFailure {
|
|
fileUrl: string
|
|
reason: string
|
|
}
|
|
|
|
export interface MaterialStorageCleanupResult {
|
|
deletedFiles: number
|
|
skippedFiles: number
|
|
failedFiles: number
|
|
failures: MaterialStorageCleanupFailure[]
|
|
}
|
|
|
|
function parseUrlList(value?: string | null): string[] {
|
|
if (!value) return []
|
|
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
if (Array.isArray(parsed)) {
|
|
return parsed.filter((url): url is string => typeof url === 'string' && url.trim().length > 0)
|
|
}
|
|
} catch {
|
|
// 兼容历史单值字段。
|
|
}
|
|
|
|
return value.trim() ? [value] : []
|
|
}
|
|
|
|
export function collectMaterialStorageUrls(material: MaterialStorageLike): string[] {
|
|
const urls = [
|
|
material.zipFilePath,
|
|
material.videoSourceUrl && material.videoSourceUrl !== material.zipFilePath ? material.videoSourceUrl : null,
|
|
material.previewUrl,
|
|
material.videoPreview540Url,
|
|
material.videoPreviewClipUrl,
|
|
material.videoCoverUrl,
|
|
...parseUrlList(material.previewUrls),
|
|
...parseUrlList(material.displayImages),
|
|
]
|
|
|
|
return Array.from(new Set(urls.filter((url): url is string => Boolean(url?.trim()))))
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) return error.message
|
|
if (typeof error === 'string') return error
|
|
return '未知错误'
|
|
}
|
|
|
|
function isExplicitLocalPath(fileUrl: string): boolean {
|
|
return fileUrl.startsWith('file://') ||
|
|
isAbsolute(fileUrl) ||
|
|
/^[a-zA-Z]:[\\/]/.test(fileUrl) ||
|
|
fileUrl.startsWith('./') ||
|
|
fileUrl.startsWith('../')
|
|
}
|
|
|
|
function shouldDeleteFromS3(fileUrl: string): boolean {
|
|
if (isS3Url(fileUrl)) return true
|
|
|
|
const storageProvider = (process.env.STORAGE_PROVIDER || '').trim().toLowerCase()
|
|
return storageProvider === 's3' && !isExplicitLocalPath(fileUrl)
|
|
}
|
|
|
|
export async function deleteMaterialStorageFiles(
|
|
fileUrls: string[],
|
|
options: { logPrefix?: string } = {}
|
|
): Promise<MaterialStorageCleanupResult> {
|
|
const logPrefix = options.logPrefix || '[MaterialStorageCleanup]'
|
|
const uniqueFiles = Array.from(new Set(fileUrls.filter((url) => url && url.trim())))
|
|
const result: MaterialStorageCleanupResult = {
|
|
deletedFiles: 0,
|
|
skippedFiles: 0,
|
|
failedFiles: 0,
|
|
failures: [],
|
|
}
|
|
|
|
for (const fileUrl of uniqueFiles) {
|
|
try {
|
|
if (shouldDeleteFromS3(fileUrl)) {
|
|
await deleteFromS3(fileUrl)
|
|
result.deletedFiles++
|
|
continue
|
|
}
|
|
|
|
try {
|
|
await unlink(fileUrl)
|
|
result.deletedFiles++
|
|
} catch (fileError: any) {
|
|
if (fileError?.code === 'ENOENT') {
|
|
result.skippedFiles++
|
|
continue
|
|
}
|
|
|
|
throw fileError
|
|
}
|
|
} catch (error) {
|
|
const reason = getErrorMessage(error)
|
|
console.error(`${logPrefix} Failed to delete file: ${fileUrl}`, error)
|
|
result.failedFiles++
|
|
result.failures.push({ fileUrl, reason })
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|