import { createHash } from 'node:crypto' import path from 'node:path' import type { Prisma } from '@prisma/client' import { getUploadRootDir } from '@/lib/upload-paths' import type { PurgeFile } from '@/lib/material-purge-files' type ReferenceRow = { sources: string[]; derived: boolean } const hash = (value: string) => createHash('md5').update(value).digest('hex') /** 索引只找候选;存储归属、历史引用及共用文件仍由原生路径规则核对。 */ export function purgeReferenceLookupKeys(files: readonly PurgeFile[]): string[] { const keys = new Set(['url:resolve']) const root = path.resolve(getUploadRootDir()) const windows = process.platform === 'win32' if (windows) keys.add('windows:unicode') for (const file of files) { const aliases = file.storage === 's3' ? [`/${file.key}`, `/${file.bucket}/${file.key}`] : [file.key, `uploads/${file.key}`, `/uploads/${file.key}`, `./uploads/${file.key}`, path.join(root, file.key), path.relative(process.cwd(), path.join(root, file.key)), `./${path.relative(process.cwd(), path.join(root, file.key))}`] for (const alias of aliases) { let normalized = alias.replace(/\\/g, '/').replace(/\/{2,}/g, '/') while (/(^|\/)\.(\/|$)/.test(normalized)) normalized = normalized.replace(/(^|\/)\.(\/|$)/g, '$1') keys.add(windows ? `ascii:${hash(normalized.replace(/[A-Z]/g, char => char.toLowerCase()))}` : `path:${hash(normalized)}`) } if (file.storage === 's3') { const marker = 'public-display/' const index = file.key.indexOf(marker) if (index >= 0) keys.add(`derived:${hash(file.key.slice(index + marker.length))}`) } } return Array.from(keys) } export async function findIndexedPurgeReferenceSources(db: Prisma.TransactionClient, files: readonly PurgeFile[]): Promise { const keys = purgeReferenceLookupKeys(files) // MATERIALIZED 与 OFFSET 0 保留参数化查找边界,避免 custom plan // 把大量检索键误估为命中全库;同一批键仍只访问每个引用索引一次。 return db.$queryRaw` WITH lookup AS MATERIALIZED (SELECT ${keys}::text[] AS keys) SELECT hits.sources, hits.derived FROM lookup CROSS JOIN LATERAL ( SELECT ARRAY["previewUrl", "zipFilePath", "videoSourceUrl", "videoPreview540Url", "videoPreviewClipUrl", "videoCoverUrl"] || "displayImages" AS sources, false AS derived FROM materials WHERE purge_reference_keys(ARRAY["previewUrl", "zipFilePath", "videoSourceUrl", "videoPreview540Url", "videoPreviewClipUrl", "videoCoverUrl"] || "displayImages", false) && lookup.keys UNION ALL SELECT ARRAY[file_url], true FROM material_assets WHERE purge_reference_keys(ARRAY[file_url], true) && lookup.keys UNION ALL SELECT ARRAY[file_path, preview_url], false FROM material_skus WHERE purge_reference_keys(ARRAY[file_path, preview_url], false) && lookup.keys UNION ALL SELECT ARRAY[file_path, preview_image_path] || preview_image_slices, false FROM brand_knowledge WHERE purge_reference_keys(ARRAY[file_path, preview_image_path] || preview_image_slices, false) && lookup.keys UNION ALL SELECT ARRAY["headerLogoUrl", "loginLogoUrl"], false FROM system_settings WHERE purge_reference_keys(ARRAY["headerLogoUrl", "loginLogoUrl"], false) && lookup.keys UNION ALL SELECT purge_json_reference_strings(image_urls), false FROM performance_survey_work_items WHERE purge_reference_keys(purge_json_reference_strings(image_urls), false) && lookup.keys UNION ALL SELECT purge_json_reference_strings(image_urls), false FROM work_review_versions WHERE purge_reference_keys(purge_json_reference_strings(image_urls), false) && lookup.keys UNION ALL SELECT purge_json_reference_strings(assets), false FROM product_package_upload_jobs WHERE status IN ('queued', 'processing') AND purge_reference_keys(purge_json_reference_strings(assets), false) && lookup.keys UNION ALL SELECT purge_json_reference_strings(operations), false FROM task_detail_change_requests WHERE status NOT IN ('applied', 'rejected', 'expired', 'cancelled') AND purge_reference_keys(purge_json_reference_strings(operations), false) && lookup.keys OFFSET 0 ) AS hits ` }