import { getMediaBlob } from "@/services/file-storage"; import { getImageBlob } from "@/services/image-storage"; import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteUserDataSnapshot, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data"; import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; import type { Asset } from "@/stores/use-asset-store"; import { useAssetStore } from "@/stores/use-asset-store"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; let activeRemoteUserId = ""; let applyingRemoteState = false; let syncTimer: number | null = null; let syncPromise: Promise | null = null; let syncQueued = false; let syncPauseCount = 0; let syncPauseTail: Promise = Promise.resolve(); let resumeSync: (() => void) | null = null; let syncResumed: Promise | null = null; const activeRemoteSyncOperations = new Set>(); let subscriptionsInstalled = false; let remoteAssetVersions = new Map(); let remoteProjectVersions = new Map(); const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audio-reference):/; export async function syncRemoteUserData(userId?: string | null) { await runRemoteUserDataSyncOperation(async () => { activeRemoteUserId = userId || ""; if (!activeRemoteUserId) return; applyingRemoteState = true; try { // 登录只拉一次聚合快照。摘要列表再逐条请求详情会把 N 条数据放大成 2N+2 个请求, // 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。 const snapshot = await getRemoteUserDataSnapshot(); remoteProjectVersions = versionMap(snapshot.projects); remoteAssetVersions = versionMap(snapshot.assets); const localProjects = useCanvasStore.getState().projects; const localAssets = useAssetStore.getState().assets; const mergedProjects = mergeById(localProjects, snapshot.projects); // 这里只合并结构化素材数据,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。 const mergedAssets = mergeById(localAssets, snapshot.assets); useCanvasStore.getState().replaceProjects(mergedProjects); useAssetStore.getState().replaceAssets(mergedAssets); } finally { applyingRemoteState = false; } }); // 首次登录可能带有尚未创建到云端的本地画布;先完成一次 upsert,避免详情页保存/分享先于项目创建。 try { await saveRemoteUserDataNow(); } catch (error) { console.warn("登录后画布首次同步失败,保留本地项目等待重试", error); } scheduleRemoteUserDataSync(); } export function installRemoteUserDataAutoSync() { if (subscriptionsInstalled) return; subscriptionsInstalled = true; useCanvasStore.subscribe((state, previous) => { if (state.projects !== previous.projects) scheduleRemoteUserDataSync(); }); useAssetStore.subscribe((state, previous) => { if (state.assets !== previous.assets) scheduleRemoteUserDataSync(); }); } export function resetRemoteUserDataSync() { activeRemoteUserId = ""; remoteAssetVersions.clear(); remoteProjectVersions.clear(); if (syncTimer) { window.clearTimeout(syncTimer); syncTimer = null; } syncQueued = false; } function waitForRemoteUserDataSyncResume() { if (!syncPauseCount) return Promise.resolve(); if (!syncResumed) { syncResumed = new Promise((resolve) => { resumeSync = resolve; }); } return syncResumed; } async function runRemoteUserDataSyncOperation(operation: () => Promise): Promise { while (syncPauseCount) await waitForRemoteUserDataSyncResume(); let finish!: () => void; const active = new Promise((resolve) => { finish = resolve; }); activeRemoteSyncOperations.add(active); try { return await operation(); } finally { activeRemoteSyncOperations.delete(active); finish(); } } export async function withRemoteUserDataSyncPaused(operation: () => Promise): Promise { syncPauseCount += 1; if (syncTimer) { window.clearTimeout(syncTimer); syncTimer = null; syncQueued = true; } let releaseTurn!: () => void; const previousTurn = syncPauseTail; syncPauseTail = new Promise((resolve) => { releaseTurn = resolve; }); await previousTurn; try { if (activeRemoteSyncOperations.size) { await Promise.allSettled([...activeRemoteSyncOperations]); } if (syncPromise) await syncPromise; return await operation(); } finally { releaseTurn(); syncPauseCount -= 1; if (!syncPauseCount) { const resolve = resumeSync; resumeSync = null; syncResumed = null; resolve?.(); if (syncQueued) scheduleRemoteUserDataSync(); } } } export function scheduleRemoteUserDataSync() { if (!activeRemoteUserId || applyingRemoteState) return; if (syncPauseCount) { syncQueued = true; if (syncTimer) { window.clearTimeout(syncTimer); syncTimer = null; } return; } if (syncPromise) { syncQueued = true; return; } if (syncTimer) window.clearTimeout(syncTimer); syncTimer = window.setTimeout(() => { syncTimer = null; void saveRemoteUserDataNow().catch((error) => console.warn("云端自动同步失败", error)); }, 1200); } export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial>) { const id = useCanvasStore.getState().createProject(title, projectId); if (initialContent) useCanvasStore.getState().updateProject(id, initialContent); if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") }; try { await saveRemoteUserDataNow(); return { id }; } catch (syncError) { scheduleRemoteUserDataSync(); return { id, syncError }; } } export async function deleteAssetWithRemoteSync(id: string) { await runRemoteUserDataSyncOperation(async () => { if (activeRemoteUserId) { await deleteRemoteAsset(id); remoteAssetVersions.delete(id); } useAssetStore.getState().removeAsset(id); }); } export async function saveRemoteUserDataNow() { if (!activeRemoteUserId) return; if (syncPauseCount) { syncQueued = true; await waitForRemoteUserDataSyncResume(); return saveRemoteUserDataNow(); } if (syncPromise) { syncQueued = true; return syncPromise; } syncPromise = drainRemoteUserDataChanges(); try { await syncPromise; } finally { syncPromise = null; } } async function drainRemoteUserDataChanges() { do { syncQueued = false; await saveRemoteUserDataBatch(); } while (syncQueued); } async function saveRemoteUserDataBatch() { try { const currentProjects = useCanvasStore.getState().projects; const currentAssets = useAssetStore.getState().assets; const dirtyProjects = currentProjects.filter((item) => !sameVersion(remoteProjectVersions.get(item.id), item.updatedAt)); const dirtyAssets = currentAssets.filter((item) => !sameVersion(remoteAssetVersions.get(item.id), item.updatedAt)); const deletedProjectIds = missingIds(remoteProjectVersions, currentProjects); const deletedAssetIds = missingIds(remoteAssetVersions, currentAssets); if (!dirtyProjects.length && !dirtyAssets.length && !deletedProjectIds.length && !deletedAssetIds.length) return; const uploaded = new Map(); const projects = await prepareRemoteCanvasProjects(dirtyProjects, uploaded); const assets = await prepareRemoteAssets(dirtyAssets, uploaded); applyingRemoteState = true; if (projects.length) useCanvasStore.getState().replaceProjects(replaceById(currentProjects, projects)); if (assets.length) useAssetStore.getState().replaceAssets(replaceById(currentAssets, assets)); applyingRemoteState = false; // SQLite 和接口频控都要求写入保持有界;逐项提交还能准确记录已完成版本。 for (const project of projects) { await upsertRemoteCanvasProject(project); remoteProjectVersions.set(project.id, project.updatedAt); } for (const asset of assets) { await upsertRemoteAsset(asset); remoteAssetVersions.set(asset.id, asset.updatedAt); } for (const id of deletedProjectIds) { await deleteRemoteCanvasProject(id); remoteProjectVersions.delete(id); } for (const id of deletedAssetIds) { await deleteRemoteAsset(id); remoteAssetVersions.delete(id); } } finally { applyingRemoteState = false; } } async function prepareRemoteAssets(assets: Asset[], uploaded: Map) { const result: Asset[] = []; for (const asset of assets) result.push(await ensureRemoteResourceReferences(asset, uploaded)); return result; } async function prepareRemoteCanvasProjects(projects: CanvasProject[], uploaded: Map) { const result: CanvasProject[] = []; for (const project of projects) result.push(await ensureRemoteResourceReferences(project, uploaded)); return result; } async function ensureRemoteResourceReferences(value: T, uploaded = new Map()): Promise { if (!value || typeof value !== "object") return value; if (Array.isArray(value)) { const result: unknown[] = []; for (const item of value) result.push(await ensureRemoteResourceReferences(item, uploaded)); return result as T; } const next: Record = {}; for (const [key, child] of Object.entries(value)) { next[key] = await ensureRemoteResourceReferences(child, uploaded); } const storageKey = typeof next.storageKey === "string" ? next.storageKey : ""; const remoteResourceId = resourceIdFromStorageKey(storageKey); if (remoteResourceId) return applyResourceReference(next, storageKey) as T; if (!isLocalStorageKey(storageKey)) { const inline = inlineMediaDataUrl(next); if (!inline) return next as T; const resourceStorage = await uploadInlineDataUrl(inline).catch(() => ""); return (resourceStorage ? applyResourceReference(next, resourceStorage) : next) as T; } const cached = uploaded.get(storageKey); const resourceStorage = cached || (await uploadLocalStorageKey(storageKey, next).catch(() => "")); if (!resourceStorage) return next as T; uploaded.set(storageKey, resourceStorage); return applyResourceReference(next, resourceStorage) as T; } function applyResourceReference(payload: Record, storageKey: string) { const url = resourceFileUrl(storageKey.slice("resource:".length)); payload.storageKey = storageKey; for (const key of ["content", "dataUrl", "url", "coverUrl"]) { if (typeof payload[key] === "string") payload[key] = url; } return payload; } function inlineMediaDataUrl(payload: Record) { for (const key of ["dataUrl", "content", "url", "coverUrl"]) { const value = payload[key]; if (typeof value === "string" && /^data:(image|video|audio)\//i.test(value)) return value; } return ""; } async function uploadInlineDataUrl(dataUrl: string) { const blob = await (await fetch(dataUrl)).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); return resourceStorageKey(resource.id); } async function uploadLocalStorageKey(storageKey: string, payload: Record) { const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey); if (!blob) return ""; const kind = blob.type.startsWith("image/") ? "image" : blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file"; const resource = await uploadResourceFile(blob, kind, { width: numberValue(payload.naturalWidth) || numberValue(payload.width), height: numberValue(payload.naturalHeight) || numberValue(payload.height), durationMs: numberValue(payload.durationMs), }); return resourceStorageKey(resource.id); } function mergeById(local: T[], remote: T[]) { const items = new Map(); remote.forEach((item) => { if (item.id) items.set(item.id, item); }); local.forEach((item) => { if (!item.id) return; const current = items.get(item.id); if (!current || timeValue(item.updatedAt) >= timeValue(current.updatedAt)) items.set(item.id, item); }); return Array.from(items.values()).sort((a, b) => timeValue(b.updatedAt) - timeValue(a.updatedAt)); } function versionMap(items: Array<{ id: string; updatedAt?: string }>) { return new Map(items.map((item) => [item.id, item.updatedAt || ""])); } function missingIds(remote: Map, local: T[]) { const localIds = new Set(local.map((item) => item.id)); return Array.from(remote.keys()).filter((id) => !localIds.has(id)); } function replaceById(current: T[], changed: T[]) { const changedById = new Map(changed.map((item) => [item.id, item])); return current.map((item) => changedById.get(item.id) || item); } function timeValue(value?: string) { const time = value ? Date.parse(value) : 0; return Number.isFinite(time) ? time : 0; } function sameVersion(remote?: string, local?: string) { return Boolean(remote && local) && timeValue(remote) === timeValue(local); } function isLocalStorageKey(value: string) { return LOCAL_STORAGE_KEY_PATTERN.test(value) && !resourceIdFromStorageKey(value); } function numberValue(value: unknown) { const number = Number(value); return Number.isFinite(number) && number > 0 ? number : undefined; }