fix(assets): 素材同步 - 远程记录入库前归一化避免缺字段崩溃

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-09 09:46:18 +08:00
parent e6ddf9451d
commit 7336945623
2 changed files with 59 additions and 3 deletions

View File

@@ -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<typeof listRemote
return result;
}
function acceptRemoteAssets(assets: Asset[]) {
function acceptRemoteAssets(remoteAssets: Asset[]) {
// 服务端素材是历史遗留形状不定的 payload:早期记录可能没有 tags、图片尺寸等字段。
// 归一化必须发生在进入 store 之前,否则页面会拿到不符合 Asset 类型的记录;
// acknowledged 基线也要用同一份对象,避免归一化差异把素材永久标记为待上传。
const assets = remoteAssets.map(normalizeAssetRecord);
const current = new Map(useAssetStore.getState().assets.map((asset) => [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";

View File

@@ -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<string, string>();
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;