feat(#1714): 前端 prepare 短路(后端 skip_transfer 命中时跳过 OSS 直传) #1729
@@ -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
|
||||
}
|
||||
|
||||
/** 直传完成确认返回 */
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -26,6 +26,8 @@ interface FakeHandle {
|
||||
fields: Record<string, string>
|
||||
max_size_bytes: number
|
||||
asset_id: string
|
||||
duplicated?: boolean
|
||||
skip_transfer?: boolean
|
||||
}
|
||||
transfer: ReturnType<typeof vi.fn>
|
||||
complete: ReturnType<typeof vi.fn>
|
||||
@@ -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<typeof vi.fn>).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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user