/** * 上传去重 / 幂等工具(Issue #1714) * * 背景:同一文件被反复入队、complete 超时后盲目重传,导致后端创建大量重复 * PROCESSING 素材记录。本模块提供两类纯函数: * * 1. 文件指纹: * - makeFileFingerprint():文件名+大小+lastModified,入队去重用(同步、零开销) * - computeFileHash():SHA-256 内容哈希(小文件全量、大文件抽样头尾), * prepare/complete 时发给后端打开 file_hash 去重闸门 * 2. 队列去重:findDuplicateInQueue() 判断文件是否已在队列中 * 3. 幂等 token:makeClientUploadId() 生成上传幂等 ID(每次"一次逻辑上传"一个, * 重试复用同一 ID,重新入队才生成新 ID) */ /** 全量哈希阈值:≤64MB 全量读入计算;超过即走头尾抽样,避免 100~256MB 视频被整文件读进内存卡死页面 */ export const HASH_FULL_READ_LIMIT = 64 * 1024 * 1024 // 64MB /** 抽样读取的头尾片段大小(各 16MB) */ export const HASH_SAMPLE_CHUNK = 16 * 1024 * 1024 /** 计算指纹时,文件在队列中已存在的状态(已失败的可以重试,不算重复) */ export type DedupExcludeStatus = "error" | "done" /** * 文件入队指纹:同库 + 文件名 + 大小 + 修改时间。 * 同一文件(File 对象由 重选或拖拽重复触发时三个字段均一致)稳定复现; * 不同文件极小概率碰撞时可由后端 file_hash 内容去重兜底。 */ export function makeFileFingerprint(file: Pick): string { return `${file.name}::${file.size}::${file.lastModified}` } /** * 在现有队列项中查找同一文件的在途记录。 * 已失败(error)的项允许重试路径复用、已完成(done)的可跳过; * 处于 preparing/uploading/ingesting 的在途项一律视为重复,禁止重复入队。 * * 返回命中的队列项 id(tempId),未命中返回 null。 */ export function findDuplicateInQueue( queue: T[], fileKey: string, excludeStatuses: DedupExcludeStatus[] = [], ): T | null { const exclude = new Set(excludeStatuses) return queue.find((it) => it.fileKey === fileKey && !exclude.has(it.status)) ?? null } /** 生成上传幂等 token:一次"逻辑上传"一个,重试复用、重新入队换新 */ export function makeClientUploadId(): string { const rand = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${Math.random() .toString(36) .slice(2, 10)}` return `up_${Date.now().toString(36)}_${rand.replace(/-/g, "").slice(0, 16)}` } /** 读取 Blob/File 片段为 ArrayBuffer:优先 Blob.arrayBuffer(),老环境回退 FileReader */ function readAsArrayBuffer(blob: Blob): Promise { if (typeof blob.arrayBuffer === "function") { return blob.arrayBuffer() } return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(reader.result as ArrayBuffer) reader.onerror = () => reject(reader.error ?? new Error("FileReader read failed")) reader.readAsArrayBuffer(blob) }) } /** * 把 buffer 复制到当前 JS realm 的 Uint8Array 再哈希。 * jsdom/测试环境中 Blob.arrayBuffer() 可能返回另一 realm 的 ArrayBuffer, * Node WebCrypto 的 WebIDL instanceof 校验会拒绝跨 realm 参数。 */ async function digestSha256(buffer: ArrayBuffer): Promise { const subtle = typeof globalThis !== "undefined" && globalThis.crypto ? globalThis.crypto.subtle : null if (!subtle) throw new Error("crypto.subtle unavailable") const local = new Uint8Array(buffer.byteLength) local.set(new Uint8Array(buffer)) return subtle.digest("SHA-256", local) } function toHex(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer) let hex = "" for (let i = 0; i < bytes.length; i += 1) { hex += bytes[i].toString(16).padStart(2, "0") } return hex } /** * 计算文件内容 SHA-256(hex,64 字符,与后端 file_hash 字段长度一致)。 * - ≤64MB:全量哈希,内容一致必然一致 * - >64MB:哈希「头部 16MB + 尾部 16MB + 文件大小」,视频素材体积大、 * 头部含 moov 元数据、尾部含 mdat 结尾,抽样碰撞概率可忽略, * 且避免 100~256MB 视频被整文件读进内存导致页面卡死/崩溃 * * 运行环境不支持 crypto.subtle(非安全上下文/老浏览器)时返回空字符串, * 调用方据此降级为不传 hash(后端仍有幂等 token + 同文件名兜底去重)。 */ export async function computeFileHash(file: File): Promise { try { const subtle = typeof globalThis !== "undefined" && globalThis.crypto && typeof globalThis.crypto.subtle?.digest === "function" ? globalThis.crypto.subtle : null if (!subtle) return "" if (file.size <= HASH_FULL_READ_LIMIT) { const data = await readAsArrayBuffer(file.slice(0, file.size)) return toHex(await digestSha256(data)) } // 大文件:头 8MB + 尾 8MB + 大小,拼成一段后哈希 const head = await readAsArrayBuffer(file.slice(0, HASH_SAMPLE_CHUNK)) const tail = file.size > HASH_SAMPLE_CHUNK ? await readAsArrayBuffer(file.slice(Math.max(0, file.size - HASH_SAMPLE_CHUNK), file.size)) : new ArrayBuffer(0) const merged = new Uint8Array(head.byteLength + tail.byteLength + 8) merged.set(new Uint8Array(head), 0) merged.set(new Uint8Array(tail), head.byteLength) const sizeView = new DataView(merged.buffer, head.byteLength + tail.byteLength, 8) // 文件大小以 64 位大端写入(BigInt 最稳;不支持 BigInt64 时手算高低位) if (typeof sizeView.setBigUint64 === "function") { sizeView.setBigUint64(0, BigInt(file.size), false) } else { sizeView.setUint32(0, Math.floor(file.size / 0x100000000), false) sizeView.setUint32(4, file.size >>> 0, false) } return toHex(await digestSha256(merged.buffer)) } catch (err) { console.warn("[uploadDedup] 计算文件哈希失败,降级为不传 file_hash:", err) return "" } }