import { useState, useCallback, useRef, useEffect } from "react" import { useQueryClient } from "@tanstack/react-query" import { message } from "antd" import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets" import { getErrorMessage, isErrorMsgShown } from "@/api/errors" import { MAX_FILE_SIZE } from "../constants" import { computeFileHash, findDuplicateInQueue, makeClientUploadId, makeFileFingerprint, } from "@/api/assets/uploadDedup" /** 单文件上传状态机 */ export type UploadItemStatus = "preparing" | "uploading" | "ingesting" | "done" | "error" /** 失败发生的阶段:complete 阶段失败时记录可能已在后端建成,禁止盲目重传整个文件 */ export type UploadFailStage = "prepare" | "transfer" | "complete" export interface UploadItem { /** 前端临时 id(prepare 前无 asset_id 时用),同时作为队列项 key */ tempId: string file: File fileName: string /** 进度 0~100(仅直传阶段有真实进度) */ progress: number status: UploadItemStatus /** 文件指纹(name+size+lastModified),入队去重用 */ fileKey: string /** 本次逻辑上传的幂等 token:重试复用、重新入队才换新 */ clientUploadId: string /** 上传前算好的文件内容哈希(SHA-256),prepare/complete 都带上 */ fileHash?: string /** 后端 prepare 预建的 asset id(旧后端可能为空) */ assetId?: string /** 去重命中:complete 返回 duplicated,标记完成但不产生新素材 */ duplicated?: boolean /** 失败发生的阶段;complete 阶段失败点重试只重发 complete,不重新上传文件 */ failedStage?: UploadFailStage /** 状态行补充提示(如"正在计算文件指纹…") */ hint?: string error?: string } /** 批量直传最大并发数,避免多文件瓜分上行带宽 */ const MAX_CONCURRENT = 3 /** complete 阶段失败后的安全提示:素材可能已在后端建成,重试只重发 complete 幂等安全 */ export const COMPLETE_RETRY_HINT = "素材可能已在服务器处理中,点重试将安全确认,不会重新上传文件" /** 失败阶段中文名(toast 提示用,明确失败发生在哪一步) */ const STAGE_LABEL: Record = { prepare: "准备上传", transfer: "文件传输", complete: "确认入库", } /** * 素材批量上传 Hook * - 入队按文件指纹(name+size+lastModified)去重:同一文件已在队列/上传中/处理中时不重复入队 * - 上传前计算文件 SHA-256,prepare/complete 携带 file_hash + 幂等 token(clientUploadId) * - complete 超时/失败不盲目重传:复用 handle 只重发 complete(幂等),prepare/transfer 失败才全量重跑 * - OSS 直传并发限制为 3,其余排队;每个文件独立进度/状态 * - 失败卡片支持重试/移除 */ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) { const queryClient = useQueryClient() const [items, setItems] = useState([]) const itemsRef = useRef([]) itemsRef.current = items /** * prepare 成功后的 handle 按 tempId 留存: * complete 阶段失败(超时/网络)时 OSS 文件已存在、后端记录也可能已建成, * 重试必须复用同一 handle 只重发 complete,绝不能重新 prepare+直传。 */ const handlesRef = useRef>(new Map()) const updateItem = useCallback((tempId: string, patch: Partial) => { setItems((prev) => prev.map((it) => (it.tempId === tempId ? { ...it, ...patch } : it))) }, []) /** 刷新素材列表(prepare 后/complete 后调用,让卡片即时出现/流转) */ const refreshList = useCallback(() => { // 使用 refetchQueries 强制立即重新获取,避免 staleTime 导致延迟 if (effectiveLibId) { queryClient.refetchQueries({ queryKey: ["assets", effectiveLibId] }) } queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) }, [queryClient, effectiveLibId]) /** * 执行单个文件的完整上传流程。 * @param completeOnly complete 阶段失败后的重试:跳过 hash/prepare/transfer,只重发 complete * (OSS 文件已传完,重发由 file_hash + clientUploadId 保证幂等) */ const runUpload = useCallback( async (item: UploadItem, handle?: DirectUploadHandle, completeOnly = false) => { let stage: UploadFailStage = "prepare" try { let h = handle if (completeOnly && h) { // ── complete 重试:文件已在 OSS,直接幂等重发确认 ── stage = "complete" updateItem(item.tempId, { status: "ingesting", progress: 100, error: undefined, failedStage: undefined, hint: undefined, }) const result = await h.complete() refreshList() handlesRef.current.delete(item.tempId) if (result.duplicated) { updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id }) message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`) } else { updateItem(item.tempId, { status: "done", assetId: result.asset_id || item.assetId }) message.success(`"${item.fileName}" 上传完成,正在转码处理`) } return } // 1. 计算文件内容哈希(失败不阻塞,降级为不传 hash;后端仍有幂等 token 兜底) updateItem(item.tempId, { hint: "正在计算文件指纹…" }) const fileHash = item.fileHash || (await computeFileHash(item.file)) updateItem(item.tempId, { fileHash, hint: undefined }) // 2. prepare(携带 file_hash + 幂等 token;重试时复用同一 clientUploadId) stage = "prepare" h = h ?? (await prepareDirectUploadHandle({ file: item.file, library_id: effectiveLibId, fileHash, clientUploadId: item.clientUploadId, })) handlesRef.current.set(item.tempId, h) // prepare 阶段后端 file_hash 命中素材库已有相同文件(skip_transfer / duplicated): // 立即标记 done、调一次 refreshList 让已存在素材立即显示,跳过 transfer + complete if (h.prepared.skip_transfer || h.prepared.duplicated) { updateItem(item.tempId, { status: "done", duplicated: true, assetId: h.prepared.asset_id, }) handlesRef.current.delete(item.tempId) refreshList() message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`) return } if (h.prepared.asset_id) { updateItem(item.tempId, { status: "uploading", assetId: h.prepared.asset_id, progress: 0, }) // 预建 asset 已入库,立即刷新让「上传中」卡片出现在网格 refreshList() } else { updateItem(item.tempId, { status: "uploading", progress: 0 }) } // 3. OSS 直传(真实进度) stage = "transfer" await h.transfer((pct) => updateItem(item.tempId, { progress: pct })) // 4. complete:后端确认入库并创建 ingest job(file_hash + 幂等 token 已在 handle 闭包中) stage = "complete" updateItem(item.tempId, { status: "ingesting", progress: 100 }) const result = await h.complete() refreshList() handlesRef.current.delete(item.tempId) if (result.duplicated) { updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id }) message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`) } else { updateItem(item.tempId, { status: "done", assetId: result.asset_id || item.assetId }) message.success(`"${item.fileName}" 上传完成,正在转码处理`) } } catch (err: unknown) { // 完整失败原因:HTTP 状态码 / OSS XML 的 Code+Message / 后端 detail, // 由 getErrorMessage 统一提取(OSS XHR 错误自带「OSS 直传失败: HTTP xxx ...」明细) const detail = getErrorMessage(err, "未知错误") console.error("[useAssetUpload] 上传失败:", item.fileName, stage, err) if (stage === "complete") { // complete 失败(超时/5xx/网络):后端记录可能已建成,handle 保留供幂等重试; // 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作。 // 卡片同时展示真实错误原因 + 安全重试提示(重试只重发 complete,不重新上传) refreshList() updateItem(item.tempId, { status: "error", failedStage: "complete", error: `${detail}\n${COMPLETE_RETRY_HINT}`, hint: undefined, }) if (!isErrorMsgShown(err)) { message.error(`"${item.fileName}" 确认入库失败:${detail}`) } } else { // prepare / transfer 失败:后端尚无素材记录,可安全全量重跑 handlesRef.current.delete(item.tempId) updateItem(item.tempId, { status: "error", failedStage: stage === "transfer" ? "transfer" : "prepare", error: detail, hint: undefined, }) // 拦截器已对后端错误弹过 toast(含真实 detail)时不重复弹; // OSS XHR 直传错误不走 axios,必须在这里弹 if (!isErrorMsgShown(err)) { message.error(`"${item.fileName}" ${STAGE_LABEL[stage]}失败:${detail}`) } } } }, [effectiveLibId, refreshList, updateItem], ) /** * 队列调度:把并发槽塞满(同时在途的 prepare+transfer 不超过 MAX_CONCURRENT)。 * runUpload 在 await prepare 期间 state 仍是 preparing,多个并发 pump 若只看 state * 会重复认领同一项,因此用 claimedRef 记录已被认领的 tempId。 */ const inFlightRef = useRef(0) const claimedRef = useRef>(new Set()) const pumpRef = useRef<() => void>(() => {}) pumpRef.current = () => { while (inFlightRef.current < MAX_CONCURRENT) { const next = itemsRef.current.find( (it) => it.status === "preparing" && !claimedRef.current.has(it.tempId), ) if (!next) return claimedRef.current.add(next.tempId) inFlightRef.current += 1 // complete 阶段失败的重试:复用留存的 handle,只重发 complete const existingHandle = handlesRef.current.get(next.tempId) void runUpload(next, existingHandle, existingHandle !== undefined).finally(() => { inFlightRef.current -= 1 claimedRef.current.delete(next.tempId) // 一个任务结束(成功/失败)后继续拉起排队任务 setTimeout(() => pumpRef.current(), 0) }) } } useEffect(() => { pumpRef.current() }, [items]) /** * 入队一个或多个文件(按文件指纹去重): * - 同一文件已在队列且 preparing/uploading/ingesting/done → 跳过,不重复入队 * - 同一文件此前失败(error)→ 重新激活原队列项(复用 clientUploadId,保持幂等语义) */ const enqueueUploads = useCallback( (files: File[]) => { if (!effectiveLibId) { message.warning("请先选择或创建一个视频库") return } const valid: File[] = [] for (const file of files) { if (file.size > MAX_FILE_SIZE) { message.error(`文件 "${file.name}" 超过 2GB 限制`) continue } valid.push(file) } if (valid.length === 0) return let skipped = 0 let rearmed = 0 const newItems: UploadItem[] = [] for (const file of valid) { const fileKey = makeFileFingerprint(file) // error 项允许重新激活;其余状态(preparing/uploading/ingesting/done)都算重复 const dup = findDuplicateInQueue([...itemsRef.current, ...newItems], fileKey, ["error"]) if (dup) { skipped += 1 continue } // 失败项重新激活:复用 tempId/clientUploadId,由 pump 按留存 handle 决定重试方式 const failed = itemsRef.current.find( (it) => it.fileKey === fileKey && it.status === "error", ) if (failed) { rearmed += 1 updateItem(failed.tempId, { status: "preparing", progress: 0, error: undefined, failedStage: undefined, hint: undefined, }) continue } newItems.push({ tempId: `${Date.now()}-${newItems.length}-${Math.random().toString(36).slice(2, 8)}`, file, fileName: file.name, progress: 0, status: "preparing", fileKey, clientUploadId: makeClientUploadId(), }) } if (newItems.length > 0) { setItems((prev) => [...prev, ...newItems]) } if (skipped > 0) { message.warning(`已跳过 ${skipped} 个重复文件(已在上传队列、处理中或本页已上传)`) } if (rearmed > 0) { message.info(`已重新加入 ${rearmed} 个此前失败的文件`) } }, [effectiveLibId, updateItem], ) /** * 重试失败任务(仅限 status=error): * - complete 阶段失败:复用留存 handle 只重发 complete(幂等,不重新上传) * - prepare/transfer 阶段失败:全量重跑(后端尚无记录,安全) */ const retryUpload = useCallback( (tempId: string) => { const target = itemsRef.current.find((it) => it.tempId === tempId) if (!target || target.status !== "error") return updateItem(tempId, { status: "preparing", progress: 0, error: undefined, hint: undefined }) // 状态更新后由 useEffect 触发 pump;pump 会按 handlesRef 自动选择 completeOnly / 全量 }, [updateItem], ) /** 从上传列表移除(已进入转码的由素材网格管理;这里只移除上传面板记录) */ const removeUpload = useCallback((tempId: string) => { handlesRef.current.delete(tempId) setItems((prev) => prev.filter((it) => it.tempId !== tempId)) }, []) /** 清空已完成/去重记录 */ const clearFinished = useCallback(() => { setItems((prev) => { for (const it of prev) { if (it.status === "done") handlesRef.current.delete(it.tempId) } return prev.filter((it) => it.status !== "done") }) }, []) const activeCount = items.filter( (it) => it.status === "preparing" || it.status === "uploading", ).length const pendingCount = items.filter((it) => it.status === "preparing").length const hasActive = activeCount > 0 || items.some((it) => it.status === "ingesting") return { uploadItems: items, enqueueUploads, retryUpload, removeUpload, clearFinished, /** 是否有进行中的上传(用于上传区文案/禁用入口) */ uploading: hasActive, /** 是否有文件正在本地处理或直传(用于禁用上传入口,防重复提交) */ transferActive: activeCount > 0, activeCount, pendingCount, } }