diff --git a/apps/web/src/api/assets/types.ts b/apps/web/src/api/assets/types.ts index 0efe111ab..77ec261fc 100644 --- a/apps/web/src/api/assets/types.ts +++ b/apps/web/src/api/assets/types.ts @@ -139,6 +139,17 @@ export interface DirectUploadPrepareResult { * 旧后端不返回该字段,前端降级为无预建卡片的原有行为。 */ asset_id?: string + /** + * 后端 file_hash 命中素材库已有相同文件时为 true,前端应跳过 transfer + complete 阶段 + * 直接按「去重命中」处理(不调 transfer、不调 complete、立即刷新素材列表)。 + * 旧后端不返回该字段,前端降级为走老流程。 + */ + duplicated?: boolean + /** + * 与 duplicated 语义一致:true 表示跳过传输,前端据此短路。 + * 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。 + */ + skip_transfer?: boolean } /** 直传完成确认返回 */ diff --git a/apps/web/src/api/assets/upload.ts b/apps/web/src/api/assets/upload.ts index 3b08b2914..3a452945d 100644 --- a/apps/web/src/api/assets/upload.ts +++ b/apps/web/src/api/assets/upload.ts @@ -179,6 +179,16 @@ export const uploadAssetDirect = async (data: { fileHash, clientUploadId, }) + // prepare 阶段后端 file_hash 命中素材库已有相同文件:跳过 transfer + complete + if (handle.prepared.skip_transfer || handle.prepared.duplicated) { + return { + storage_key: handle.prepared.storage_key, + ingest_job_id: "", + url: "", + duplicated: true, + asset_id: handle.prepared.asset_id, + } + } await handle.transfer(data.onProgress) return handle.complete() } diff --git a/apps/web/src/pages/assets/hooks/useAssetUpload.ts b/apps/web/src/pages/assets/hooks/useAssetUpload.ts index d322e3d5e..528cf32ce 100644 --- a/apps/web/src/pages/assets/hooks/useAssetUpload.ts +++ b/apps/web/src/pages/assets/hooks/useAssetUpload.ts @@ -141,6 +141,20 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) { })) 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", diff --git a/apps/web/src/test/api/assets.test.ts b/apps/web/src/test/api/assets.test.ts index 71f5788d7..90967f1f5 100644 --- a/apps/web/src/test/api/assets.test.ts +++ b/apps/web/src/test/api/assets.test.ts @@ -259,6 +259,112 @@ describe("assets API", () => { }) }) + describe("uploadAssetDirect skip_transfer 短路", () => { + it("prepare 返回 skip_transfer=true → 直接返回 duplicated,不调 transfer/complete", async () => { + mockPost.mockImplementation((url: string) => { + if (url === "/upload/direct/prepare") { + return Promise.resolve({ + data: { + upload_url: "https://oss/x", + method: "POST", + storage_key: "uploads/skip/y.mp4", + expires_at: "2099", + fields: {}, + max_size_bytes: 1e9, + asset_id: "existing-asset", + skip_transfer: true, + duplicated: true, + }, + }) + } + if (url === "/upload/direct/complete") { + throw new Error("complete 不应被调用") + } + throw new Error("unexpected url " + url) + }) + const putSpy = vi.spyOn(globalThis, "XMLHttpRequest") + const file = new File(["x"], "x.mp4", { type: "video/mp4" }) + const result = await uploadAssetDirect({ file, library_id: "lib-1" }) + expect(result.duplicated).toBe(true) + expect(result.asset_id).toBe("existing-asset") + // complete 未被调用(mockPost 只记录 prepare,complete 若调用会抛 "不应被调用") + const completeCalls = mockPost.mock.calls.filter( + ([u]: [string]) => u === "/upload/direct/complete", + ) + expect(completeCalls).toHaveLength(0) + putSpy.mockRestore() + }) + + it("prepare 返回 skip_transfer=false → 走老流程(complete 被调用)", async () => { + mockPost.mockImplementation((url: string) => { + if (url === "/upload/direct/prepare") { + return Promise.resolve({ + data: { + upload_url: "https://oss/x", + method: "POST", + storage_key: "uploads/normal/y.mp4", + expires_at: "2099", + fields: {}, + max_size_bytes: 1e9, + asset_id: "new-asset", + }, + }) + } + if (url === "/upload/direct/complete") { + return Promise.resolve({ + data: { + storage_key: "uploads/normal/y.mp4", + ingest_job_id: "job-1", + url: "https://oss/y.mp4", + duplicated: false, + asset_id: "new-asset", + }, + }) + } + throw new Error("unexpected url " + url) + }) + // mock XMLHttpRequest:send 之后下一 tick 触发 onload 让 transfer 立即成功 + const origOpen = XMLHttpRequest.prototype.open + const origSend = XMLHttpRequest.prototype.send + const origSetReadyState = Object.getOwnPropertyDescriptor( + XMLHttpRequest.prototype, + "readyState", + ) as PropertyDescriptor | undefined + const origStatus = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, "status") + Object.defineProperty(XMLHttpRequest.prototype, "readyState", { + configurable: true, + writable: true, + value: 4, + }) + Object.defineProperty(XMLHttpRequest.prototype, "status", { + configurable: true, + writable: true, + value: 200, + }) + XMLHttpRequest.prototype.open = vi.fn() as unknown as typeof origOpen + XMLHttpRequest.prototype.send = vi.fn(function (this: XMLHttpRequest) { + // 下一 tick 触发 onload(模拟 XHR 异步完成) + setTimeout(() => this.onload?.(new ProgressEvent("load")), 0) + }) as unknown as typeof origSend + const file = new File(["x"], "x.mp4", { type: "video/mp4" }) + const result = await uploadAssetDirect({ file, library_id: "lib-1" }) + expect(result.duplicated).toBeFalsy() + expect(result.asset_id).toBe("new-asset") + const completeCalls = mockPost.mock.calls.filter( + ([u]: [string]) => u === "/upload/direct/complete", + ) + expect(completeCalls).toHaveLength(1) + XMLHttpRequest.prototype.open = origOpen + XMLHttpRequest.prototype.send = origSend + if (origSetReadyState) { + Object.defineProperty(XMLHttpRequest.prototype, "readyState", origSetReadyState) + } + if (origStatus) { + Object.defineProperty(XMLHttpRequest.prototype, "status", origStatus) + } + }) + }) + describe("getIngestJob", () => { it("should resolve successfully", async () => { await expect(getIngestJob("test-jobId")).resolves.not.toThrow() diff --git a/apps/web/src/test/pages/assets/useAssetUpload.test.tsx b/apps/web/src/test/pages/assets/useAssetUpload.test.tsx index 4c5004fa0..2ad8d634c 100644 --- a/apps/web/src/test/pages/assets/useAssetUpload.test.tsx +++ b/apps/web/src/test/pages/assets/useAssetUpload.test.tsx @@ -26,6 +26,8 @@ interface FakeHandle { fields: Record max_size_bytes: number asset_id: string + duplicated?: boolean + skip_transfer?: boolean } transfer: ReturnType complete: ReturnType @@ -46,6 +48,8 @@ const makeFakeHandle = (opts: { duplicated?: boolean failTransfer?: boolean completeAuto?: boolean + /** prepare 阶段就命中去重:prepare 响应 skip_transfer/duplicated=true */ + prepareDedup?: boolean }) => { const h: FakeHandle = { prepared: { @@ -56,6 +60,8 @@ const makeFakeHandle = (opts: { fields: {}, max_size_bytes: 2_000_000_000, asset_id: opts.id, + duplicated: opts.prepareDedup ? true : undefined, + skip_transfer: opts.prepareDedup ? true : undefined, }, transfer: vi.fn(), complete: vi.fn(), @@ -380,4 +386,27 @@ describe("useAssetUpload", () => { expect(it.failedStage).toBe("prepare") expect(it.error).toContain("签名服务内部错误") }) + it("prepare 返回 skip_transfer=true 时立即跳过 transfer+complete,标记 done+duplicated", async () => { + const h = makeFakeHandle({ id: "a-skip", prepareDedup: true }) + ;(prepareDirectUploadHandle as unknown as ReturnType).mockImplementation( + async () => h, + ) + + const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.enqueueUploads([mp4("skip-transfer.mp4")]) + }) + + await waitFor(() => { + expect(h.transfer).not.toHaveBeenCalled() + expect(h.complete).not.toHaveBeenCalled() + const it = result.current.uploadItems[0] + expect(it?.status).toBe("done") + expect(it?.duplicated).toBe(true) + expect(it?.assetId).toBe("a-skip") + }) + }) })