6f4499afff
AI Code Review / AI Code Review (pull_request) Successful in 1m55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m6s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m15s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m56s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m56s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m40s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m47s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 17m20s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
- WechatCallback/WechatBindCallback: 删除localStorage state前置校验(后端state store一次性消费兜底,微信内/跨浏览器误杀正常回调);catch透传后端真实detail;处理微信重定向error参数 - Login: 微信登录按钮加ref同步防连点(连点两次state互相覆盖) - 新增api/errors.ts统一错误提取(axios detail/422数组/状态码/网络超时) - useAssetUpload: 失败卡片记录失败阶段+HTTP状态码+OSS XML错误明细;getOrCreateDefaultProject失败独立提示;toast与拦截器去重 - UploadQueuePanel: 失败卡片多行展示完整原因(阶段+错误+安全重试提示) - uploadDedup: 全量哈希阈值256MB→64MB,抽样片段8MB→16MB,避免100~256MB视频整文件读入内存卡死 - WechatOnboarding: 昵称输入框不预填,用户必须自己输入 - 测试: 新增22用例,全量644通过
144 lines
6.2 KiB
TypeScript
144 lines
6.2 KiB
TypeScript
/**
|
||
* 上传去重 / 幂等工具(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 对象由 <input> 重选或拖拽重复触发时三个字段均一致)稳定复现;
|
||
* 不同文件极小概率碰撞时可由后端 file_hash 内容去重兜底。
|
||
*/
|
||
export function makeFileFingerprint(file: Pick<File, "name" | "size" | "lastModified">): string {
|
||
return `${file.name}::${file.size}::${file.lastModified}`
|
||
}
|
||
|
||
/**
|
||
* 在现有队列项中查找同一文件的在途记录。
|
||
* 已失败(error)的项允许重试路径复用、已完成(done)的可跳过;
|
||
* 处于 preparing/uploading/ingesting 的在途项一律视为重复,禁止重复入队。
|
||
*
|
||
* 返回命中的队列项 id(tempId),未命中返回 null。
|
||
*/
|
||
export function findDuplicateInQueue<T extends { fileKey: string; status: string }>(
|
||
queue: T[],
|
||
fileKey: string,
|
||
excludeStatuses: DedupExcludeStatus[] = [],
|
||
): T | null {
|
||
const exclude = new Set<string>(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<ArrayBuffer> {
|
||
if (typeof blob.arrayBuffer === "function") {
|
||
return blob.arrayBuffer()
|
||
}
|
||
return new Promise<ArrayBuffer>((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<ArrayBuffer> {
|
||
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<string> {
|
||
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 ""
|
||
}
|
||
}
|