fix(storage): prevent duplicate COS resource uploads
This commit is contained in:
@@ -62,6 +62,14 @@ export type ArkPrivateAssetSync = {
|
||||
status: "active" | string;
|
||||
};
|
||||
|
||||
type ResourceUploadMeta = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationMs?: number;
|
||||
fileName?: string;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
const api = apiClient;
|
||||
const resourceCache = new Map<string, RemoteResource>();
|
||||
const resourceRequests = new Map<string, Promise<RemoteResource>>();
|
||||
@@ -103,7 +111,7 @@ export function isResourceUrl(url?: string) {
|
||||
return path.startsWith(`${base}/resources/`) && path.endsWith("/file");
|
||||
}
|
||||
|
||||
export async function uploadResourceFile(file: Blob, kind: "image" | "video" | "audio" | "file", meta?: { width?: number; height?: number; durationMs?: number; fileName?: string }) {
|
||||
export async function uploadResourceFile(file: Blob, kind: "image" | "video" | "audio" | "file", meta?: ResourceUploadMeta) {
|
||||
const formData = new FormData();
|
||||
const name = meta?.fileName || (file instanceof File ? file.name : `${kind}.${extensionFromMime(file.type, kind)}`);
|
||||
formData.append("kind", kind);
|
||||
@@ -111,17 +119,22 @@ export async function uploadResourceFile(file: Blob, kind: "image" | "video" | "
|
||||
if (meta?.width) formData.append("width", String(Math.round(meta.width)));
|
||||
if (meta?.height) formData.append("height", String(Math.round(meta.height)));
|
||||
if (meta?.durationMs) formData.append("durationMs", String(Math.round(meta.durationMs)));
|
||||
const data = await request<{ resource: RemoteResource }>(api.post("/resources", formData));
|
||||
const data = await request<{ resource: RemoteResource }>(api.post("/resources", formData, uploadRequestConfig(meta?.idempotencyKey)));
|
||||
resourceCache.set(resourceCacheKey(data.resource.id), data.resource);
|
||||
return data.resource;
|
||||
}
|
||||
|
||||
export async function importResourceFromUrl(url: string, kind: "image" | "video" | "audio" | "file", meta?: { width?: number; height?: number; durationMs?: number }) {
|
||||
const data = await request<{ resource: RemoteResource }>(api.post("/resources/import", { url, kind, width: meta?.width, height: meta?.height, durationMs: meta?.durationMs }));
|
||||
export async function importResourceFromUrl(url: string, kind: "image" | "video" | "audio" | "file", meta?: Omit<ResourceUploadMeta, "fileName">) {
|
||||
const data = await request<{ resource: RemoteResource }>(api.post("/resources/import", { url, kind, width: meta?.width, height: meta?.height, durationMs: meta?.durationMs }, uploadRequestConfig(meta?.idempotencyKey)));
|
||||
resourceCache.set(resourceCacheKey(data.resource.id), data.resource);
|
||||
return data.resource;
|
||||
}
|
||||
|
||||
function uploadRequestConfig(idempotencyKey?: string) {
|
||||
const value = idempotencyKey?.trim();
|
||||
return value ? { headers: { "X-Idempotency-Key": value } } : undefined;
|
||||
}
|
||||
|
||||
export function getResource(id: string): Promise<RemoteResource> {
|
||||
const cacheKey = resourceCacheKey(id);
|
||||
const cached = resourceCache.get(cacheKey);
|
||||
|
||||
@@ -13,6 +13,8 @@ const store = localforage.createInstance({ name: "infinite-canvas", storeName: "
|
||||
const objectUrls = new Map<string, string>();
|
||||
|
||||
export async function uploadMediaFile(input: string | Blob, prefix = "file"): Promise<UploadedFile> {
|
||||
// 直传和失败后的本地同步必须复用同一上传身份,避免响应丢失后创建第二个对象。
|
||||
const storageKey = `${prefix}:${getActiveUserScope()}:${nanoid()}`;
|
||||
const blob = typeof input === "string" ? await (await fetch(input)).blob() : input;
|
||||
const previewUrl = URL.createObjectURL(blob);
|
||||
const captured = blob.type.startsWith("video/") ? await captureVideoPoster(previewUrl).catch(() => undefined) : undefined;
|
||||
@@ -29,14 +31,13 @@ export async function uploadMediaFile(input: string | Blob, prefix = "file"): Pr
|
||||
const poster = captured?.poster ? await uploadImage(captured.poster).catch(() => undefined) : undefined;
|
||||
try {
|
||||
const kind = blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
|
||||
const resource = await uploadResourceFile(blob, kind, { ...meta, fileName: input instanceof File ? input.name : undefined });
|
||||
const resource = await uploadResourceFile(blob, kind, { ...meta, fileName: input instanceof File ? input.name : undefined, idempotencyKey: storageKey });
|
||||
await primeResourceBlobCache(resourceStorageKey(resource.id), blob).catch(() => "");
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
return { url: resource.publicUrl || resourceFileUrl(resource.id), storageKey: resourceStorageKey(resource.id), bytes: resource.size || blob.size, mimeType: resource.mimeType || blob.type || "application/octet-stream", width: resource.width || meta.width, height: resource.height || meta.height, durationMs: resource.durationMs || meta.durationMs, hasAudio: meta.hasAudio, preview: poster };
|
||||
} catch {
|
||||
// OSS is optional during local/self-hosted setup. Keep the existing local fallback.
|
||||
}
|
||||
const storageKey = `${prefix}:${getActiveUserScope()}:${nanoid()}`;
|
||||
await store.setItem(storageKey, blob);
|
||||
const url = previewUrl;
|
||||
objectUrls.set(storageKey, url);
|
||||
|
||||
@@ -19,9 +19,12 @@ const store = localforage.createInstance({ name: "infinite-canvas", storeName: "
|
||||
const objectUrls = new Map<string, string>();
|
||||
|
||||
export async function uploadImage(input: string | Blob): Promise<UploadedImage> {
|
||||
// 同一个逻辑上传在直传失败后会退回 IndexedDB,并由云端数据同步再次提交。
|
||||
// 提前生成本地 key,确保两条路径向后端发送相同的幂等标识。
|
||||
const storageKey = `image:${getActiveUserScope()}:${nanoid()}`;
|
||||
if (typeof input === "string" && shouldImportRemoteImage(input)) {
|
||||
try {
|
||||
const resource = await importResourceFromUrl(input, "image");
|
||||
const resource = await importResourceFromUrl(input, "image", { idempotencyKey: storageKey });
|
||||
return {
|
||||
url: resource.publicUrl || resourceFileUrl(resource.id),
|
||||
storageKey: resourceStorageKey(resource.id),
|
||||
@@ -38,7 +41,7 @@ export async function uploadImage(input: string | Blob): Promise<UploadedImage>
|
||||
const previewUrl = URL.createObjectURL(blob);
|
||||
const meta = await readImageMeta(previewUrl);
|
||||
try {
|
||||
const resource = await uploadResourceFile(blob, "image", { width: meta.width, height: meta.height, fileName: input instanceof File ? input.name : undefined });
|
||||
const resource = await uploadResourceFile(blob, "image", { width: meta.width, height: meta.height, fileName: input instanceof File ? input.name : undefined, idempotencyKey: storageKey });
|
||||
await primeResourceBlobCache(resourceStorageKey(resource.id), blob).catch(() => "");
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
return {
|
||||
@@ -52,7 +55,6 @@ export async function uploadImage(input: string | Blob): Promise<UploadedImage>
|
||||
} catch {
|
||||
// OSS is optional during local/self-hosted setup. Keep the existing local fallback.
|
||||
}
|
||||
const storageKey = `image:${getActiveUserScope()}:${nanoid()}`;
|
||||
await store.setItem(storageKey, blob);
|
||||
const url = previewUrl;
|
||||
objectUrls.set(storageKey, url);
|
||||
|
||||
@@ -19,6 +19,7 @@ let remoteOperationTail: Promise<void> = Promise.resolve();
|
||||
let subscriptionsInstalled = false;
|
||||
let acknowledgedAssets = new Map<string, Asset>();
|
||||
let acknowledgedProjects = new Map<string, CanvasProject>();
|
||||
const resolvedResourceReferences = new Map<string, string>();
|
||||
|
||||
const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audio-reference):/;
|
||||
|
||||
@@ -27,6 +28,7 @@ export async function syncRemoteUserData(userId?: string | null) {
|
||||
activeRemoteUserId = userId || "";
|
||||
acknowledgedProjects.clear();
|
||||
acknowledgedAssets.clear();
|
||||
resolvedResourceReferences.clear();
|
||||
if (!activeRemoteUserId) {
|
||||
remoteUserDataPhase = "inactive";
|
||||
return;
|
||||
@@ -67,6 +69,7 @@ export function resetRemoteUserDataSync() {
|
||||
remoteUserDataPhase = "inactive";
|
||||
acknowledgedAssets.clear();
|
||||
acknowledgedProjects.clear();
|
||||
resolvedResourceReferences.clear();
|
||||
if (syncTimer) {
|
||||
window.clearTimeout(syncTimer);
|
||||
syncTimer = null;
|
||||
@@ -219,13 +222,18 @@ async function ensureRemoteResourceReferences<T>(value: T, uploaded = new Map<st
|
||||
if (!isLocalStorageKey(storageKey)) {
|
||||
const inline = inlineMediaDataUrl(next);
|
||||
if (!inline) return next as T;
|
||||
const resourceStorage = await uploadInlineDataUrl(inline);
|
||||
const identity = await inlineMediaUploadIdentity(inline);
|
||||
const cached = uploaded.get(identity) || resolvedResourceReferences.get(identity);
|
||||
const resourceStorage = cached || (await uploadInlineDataUrl(inline, identity));
|
||||
uploaded.set(identity, resourceStorage);
|
||||
resolvedResourceReferences.set(identity, resourceStorage);
|
||||
return applyResourceReference(next, resourceStorage) as T;
|
||||
}
|
||||
|
||||
const cached = uploaded.get(storageKey);
|
||||
const cached = uploaded.get(storageKey) || resolvedResourceReferences.get(storageKey);
|
||||
const resourceStorage = cached || (await uploadLocalStorageKey(storageKey, next));
|
||||
uploaded.set(storageKey, resourceStorage);
|
||||
resolvedResourceReferences.set(storageKey, resourceStorage);
|
||||
return applyResourceReference(next, resourceStorage) as T;
|
||||
}
|
||||
|
||||
@@ -246,15 +254,20 @@ function inlineMediaDataUrl(payload: Record<string, unknown>) {
|
||||
return "";
|
||||
}
|
||||
|
||||
async function uploadInlineDataUrl(dataUrl: string) {
|
||||
async function uploadInlineDataUrl(dataUrl: string, identity: string) {
|
||||
const response = await fetch(dataUrl);
|
||||
if (!response.ok) throw new Error("内嵌媒体读取失败");
|
||||
const blob = await response.blob();
|
||||
const kind: "image" | "video" | "audio" | "file" = blob.type.startsWith("image/") ? "image" : blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
|
||||
const resource = await uploadResourceFile(blob, kind);
|
||||
const resource = await uploadResourceFile(blob, kind, { idempotencyKey: identity });
|
||||
return resourceStorageKey(resource.id);
|
||||
}
|
||||
|
||||
async function inlineMediaUploadIdentity(dataUrl: string) {
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(dataUrl));
|
||||
return `inline:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
async function uploadLocalStorageKey(storageKey: string, payload: Record<string, unknown>) {
|
||||
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
|
||||
if (!blob) throw new Error(`本地媒体不存在:${storageKey}`);
|
||||
@@ -263,6 +276,7 @@ async function uploadLocalStorageKey(storageKey: string, payload: Record<string,
|
||||
width: numberValue(payload.naturalWidth) || numberValue(payload.width),
|
||||
height: numberValue(payload.naturalHeight) || numberValue(payload.height),
|
||||
durationMs: numberValue(payload.durationMs),
|
||||
idempotencyKey: storageKey,
|
||||
});
|
||||
return resourceStorageKey(resource.id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user