ff60fdf956
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 (push) Successful in 3s
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 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 26s
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 / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 39s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 38s
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m16s
CI/CD Pipeline / Integration Tests (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / Validate - Style (push) Successful in 2m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m10s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m31s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Validate - Security (push) Successful in 5m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m41s
AI Code Review / AI Code Review (pull_request) Successful in 6m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m32s
CI/CD Pipeline / Unit Tests (push) Successful in 8m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
382 lines
15 KiB
TypeScript
382 lines
15 KiB
TypeScript
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<UploadFailStage, string> = {
|
||
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<UploadItem[]>([])
|
||
const itemsRef = useRef<UploadItem[]>([])
|
||
itemsRef.current = items
|
||
|
||
/**
|
||
* prepare 成功后的 handle 按 tempId 留存:
|
||
* complete 阶段失败(超时/网络)时 OSS 文件已存在、后端记录也可能已建成,
|
||
* 重试必须复用同一 handle 只重发 complete,绝不能重新 prepare+直传。
|
||
*/
|
||
const handlesRef = useRef<Map<string, DirectUploadHandle>>(new Map())
|
||
|
||
const updateItem = useCallback((tempId: string, patch: Partial<UploadItem>) => {
|
||
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<Set<string>>(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,
|
||
}
|
||
}
|