diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index 6e4a8d315..b4c192a25 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -4,6 +4,7 @@ import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemote import { appQueryClient } from "@/lib/query-client"; import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; import { assetForRemoteSync } from "@/lib/asset-remote-sync"; +import { normalizeAssetRecord } from "@/lib/asset-storage-revision"; import type { Asset } from "@/stores/use-asset-store"; import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; @@ -71,7 +72,11 @@ export async function loadAssetLibraryPage(options: Parameters [asset.id, asset])); for (const asset of assets) { const local = current.get(asset.id); @@ -131,13 +136,14 @@ export async function syncRemoteUserData(userId?: string | null) { const snapshot = await getRemoteUserDataSnapshot(); // 登录时服务端是实体真相。浏览器 IndexedDB 只作为首屏缓存,不能把服务端已删除的记录补回去。 // 这里只替换结构化记录,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。 + const snapshotAssets = snapshot.assets.map(normalizeAssetRecord); useCanvasStore.getState().replaceProjects(snapshot.projects); - useAssetStore.getState().replaceAssets(snapshot.assets); + useAssetStore.getState().replaceAssets(snapshotAssets); const repair = repairMissingCanvasAssets(); repairedCanvasAssets = repair.createdAssets > 0 || repair.updatedProjects > 0; await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]); acknowledgedProjects = new Map(snapshot.projects.map((project) => [project.id, project])); - acknowledgedAssets = new Map(snapshot.assets.map((asset) => [asset.id, asset])); + acknowledgedAssets = new Map(snapshotAssets.map((asset) => [asset.id, asset])); remoteUserDataPhase = "ready"; } catch (error) { remoteUserDataPhase = "failed"; diff --git a/web/test/user-scoped-generation-persistence.test.ts b/web/test/user-scoped-generation-persistence.test.ts index 723e07acd..e165c2521 100644 --- a/web/test/user-scoped-generation-persistence.test.ts +++ b/web/test/user-scoped-generation-persistence.test.ts @@ -4441,6 +4441,56 @@ test("incremental sessions leave cached entities untouched and fetch only the op } }); +test("a remote asset page normalizes legacy records before they reach the store", async () => { + const previousAdapter = apiClient.defaults.adapter; + const previousAssets = useAssetStore.getState().assets; + const legacy = { + id: "legacy-remote-image", + kind: "image", + title: "镜头01 · 图片", + category: "image", + coverUrl: "opaque://legacy", + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + data: { dataUrl: "opaque://legacy", width: 1, height: 1, bytes: 1, mimeType: "image/png" }, + }; + apiClient.defaults.adapter = async (config) => ({ + data: { code: 0, data: { assets: [legacy], page: 1, pageSize: 40, total: 1, hasMore: false }, msg: "" }, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + const originalWindow = (globalThis as { window?: unknown }).window; + const localStorageValues = new Map(); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + setTimeout: () => 1, + clearTimeout: () => undefined, + localStorage: { + getItem: (key: string) => localStorageValues.get(key) ?? null, + setItem: (key: string, value: string) => localStorageValues.set(key, value), + removeItem: (key: string) => localStorageValues.delete(key), + }, + }, + }); + try { + useAssetStore.setState({ assets: [] }); + await initializeRemoteUserDataSession("account-legacy"); + await loadAssetLibraryPage({ page: 1, pageSize: 40 }); + const stored = useAssetStore.getState().assets.find((asset) => asset.id === legacy.id); + expect(stored?.tags).toEqual([]); + expect(() => stored?.tags.join(" ")).not.toThrow(); + } finally { + resetRemoteUserDataSync(); + apiClient.defaults.adapter = previousAdapter; + useAssetStore.setState({ assets: previousAssets }); + if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window; + else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + test("an asset page arriving after account switch cannot populate the new account", async () => { const previousAdapter = apiClient.defaults.adapter; const previousAssets = useAssetStore.getState().assets;