fix(canvas): 画布 - 防止旧内容覆盖并支持版本历史恢复
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { getMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
|
||||
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
|
||||
import { ApiError } from "@/services/api/request";
|
||||
import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { preserveCanvasSyncDraft, readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||||
import { parseAssetRecordList } from "@/lib/asset-record";
|
||||
@@ -13,7 +17,6 @@ import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
|
||||
import { repairMissingCanvasAssets, collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, type CanvasAssetRebindResult } from "@/services/canvas-asset-repair";
|
||||
import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset";
|
||||
import { sameAgentCanvasContent } from "@/lib/canvas/agent-canvas-snapshot";
|
||||
import { applyAgentCanvasPatch, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch";
|
||||
|
||||
let activeRemoteUserId = "";
|
||||
@@ -44,34 +47,89 @@ export async function initializeRemoteUserDataSession(userId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCanvasProjectForEditing(id: string) {
|
||||
const pending = remoteProjectLoadPromises.get(id);
|
||||
if (pending) return pending;
|
||||
export async function loadCanvasProjectForEditing(id: string, options: { latest?: boolean; historyRestore?: { snapshotId: string; revision: number }; onLoad?: (project: CanvasProject) => void } = {}) {
|
||||
const epoch = sessionEpoch;
|
||||
const request = withRemoteUserDataSyncExclusive(async () => {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
|
||||
const local = useCanvasStore.getState().projects.find((project) => project.id === id);
|
||||
if (!activeRemoteUserId || verifiedProjects.has(id)) return local;
|
||||
const { project } = await getRemoteCanvasProject(id);
|
||||
await loadReferencedAssets(collectAssetIds(project));
|
||||
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
|
||||
if (current && !sameEntitySnapshot(acknowledgedProjects.get(id), current)) {
|
||||
// 当前页面已经开始编辑本地缓存时,远端详情只建立新的冲突基线;
|
||||
// 保留当前画布内容,后续保存由当前打开的画布显式覆盖远端版本,
|
||||
// 避免旧缓存被永久卡在“自动重试但永远冲突”的状态。
|
||||
acknowledgedProjects.set(id, project);
|
||||
verifiedProjects.add(id);
|
||||
return current;
|
||||
requireRemoteUserDataBaseline();
|
||||
const scope = getActiveUserScope();
|
||||
if (options.historyRestore) {
|
||||
const local = useCanvasStore.getState().openProject(id);
|
||||
if (local) {
|
||||
const draftCount = await preserveCanvasSyncDraft(local, scope);
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
|
||||
}
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) {
|
||||
throw new Error("本地内容仍在更新,请稍后再恢复历史版本");
|
||||
}
|
||||
const { snapshotId, revision } = options.historyRestore;
|
||||
try {
|
||||
const saved = await restoreRemoteCanvasHistory(id, snapshotId, revision);
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布查看恢复结果");
|
||||
if (saved.project.revision !== revision + 1) throw new Error("恢复结果的版本无效,请重新核对云端内容");
|
||||
} catch (error) {
|
||||
if (epoch === sessionEpoch && getActiveUserScope() === scope && error instanceof ApiError && (error.status === 409 || error.status === 428)) {
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
let remote: CanvasProject;
|
||||
try {
|
||||
remote = (await getRemoteCanvasProject(id)).project;
|
||||
} catch (error) {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
|
||||
const local = useCanvasStore.getState().openProject(id);
|
||||
if (error instanceof ApiError && error.status === 404 && local?.revision === 0) {
|
||||
options.onLoad?.(local);
|
||||
return local;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
|
||||
await loadReferencedAssets(collectAssetIds(remote));
|
||||
const hash = await canvasContentHash(remote);
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布");
|
||||
const local = useCanvasStore.getState().openProject(id);
|
||||
const cachedClean = local?.remoteContentHash && local.remoteContentHash === await canvasContentHash(local);
|
||||
const dirty = local && (!verifiedProjects.has(id) && incrementalSession ? !cachedClean : !sameCanvasContent(acknowledgedProjects.get(id), local));
|
||||
if (dirty && !sameCanvasContent(local, remote)) {
|
||||
const draftCount = await preserveCanvasSyncDraft(local, scope);
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) throw new Error("画布仍在更新,已保留本地内容,请稍后再加载最新版本");
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
|
||||
if (!options.latest && !options.historyRestore && local.revision !== undefined) {
|
||||
if (local.revision !== remote.revision) {
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: "云端画布已有更新,本地修改已保留为草稿" });
|
||||
} else {
|
||||
// A cached draft is not an acknowledged cloud save. Once its
|
||||
// ancestor is verified, retain it as pending against that content.
|
||||
acknowledgedProjects.set(id, { ...remote, remoteContentHash: hash });
|
||||
verifiedProjects.add(id);
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "pending", message: "本地草稿等待保存到云端" });
|
||||
scheduleRemoteUserDataSync();
|
||||
}
|
||||
options.onLoad?.(local);
|
||||
return local;
|
||||
}
|
||||
}
|
||||
// Editing/generation may continue during the network request or draft write.
|
||||
// Never replace a newer local state which has not been backed up.
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) {
|
||||
throw new Error("画布仍在更新,已保留本地内容,请稍后再加载最新版本");
|
||||
}
|
||||
const project = { ...remote, viewport: local?.viewport || remote.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: hash };
|
||||
// Align live editor refs synchronously before publishing the new revision.
|
||||
// A generation callback must never see old rendered nodes paired with a new revision.
|
||||
options.onLoad?.(project);
|
||||
acknowledgedProjects.set(id, project);
|
||||
verifiedProjects.add(id);
|
||||
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), project] }));
|
||||
useCanvasStore.setState((state) => ({ projects: local ? state.projects.map((item) => item.id === id ? project : item) : [...state.projects, project] }));
|
||||
await flushCanvasStorePersistence();
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已加载云端最新版本" });
|
||||
return project;
|
||||
});
|
||||
remoteProjectLoadPromises.set(id, request);
|
||||
const clearPending = () => {
|
||||
if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id);
|
||||
};
|
||||
const clearPending = () => { if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id); };
|
||||
void request.then(clearPending, clearPending);
|
||||
return request;
|
||||
}
|
||||
@@ -92,13 +150,28 @@ export async function refreshCanvasAfterAgent(id: string) {
|
||||
const { project } = await getRemoteCanvasProject(id);
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换");
|
||||
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
|
||||
if (current && !sameAgentCanvasContent(acknowledgedProjects.get(id), current)) throw new Error("Agent 已更新服务端画布,但本地存在未同步编辑。已保留本地内容,请处理同步冲突后刷新。");
|
||||
if (current && sameEntitySnapshot(current, project)) return current;
|
||||
const projected = current ? { ...project, viewport: current.viewport } : project;
|
||||
for (const listener of agentCanvasListeners) listener(projected, current);
|
||||
const cachedDirty = current && incrementalSession && !verifiedProjects.has(id) && current.remoteContentHash !== await canvasContentHash(current);
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换");
|
||||
if (current && (cachedDirty || !sameCanvasContent(acknowledgedProjects.get(id), current))) {
|
||||
await preserveAgentConflict(current);
|
||||
throw new Error("Agent 已更新服务端画布,但本地存在未同步编辑。已保留本地草稿,请加载云端最新版本。");
|
||||
}
|
||||
if (current && current.revision === project.revision && sameCanvasContent(current, project)) return current;
|
||||
const projected = { ...project, viewport: current?.viewport || project.viewport, remoteContentHash: await canvasContentHash(project) };
|
||||
if (epoch !== sessionEpoch || useCanvasStore.getState().openProject(id) !== (current || null)) throw new Error("画布仍在更新,请重新同步 Agent 结果");
|
||||
try {
|
||||
if (!sameCanvasContent(current, projected)) {
|
||||
for (const listener of agentCanvasListeners) listener(projected, current);
|
||||
}
|
||||
} catch (error) {
|
||||
if (current) await preserveAgentConflict(current);
|
||||
throw error;
|
||||
}
|
||||
acknowledgedProjects.set(id, project);
|
||||
verifiedProjects.add(id);
|
||||
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), projected] }));
|
||||
await flushCanvasStorePersistence();
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已同步 Agent 画布结果" });
|
||||
return projected;
|
||||
});
|
||||
}
|
||||
@@ -110,19 +183,46 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa
|
||||
const baseline = acknowledgedProjects.get(id);
|
||||
const current = useCanvasStore.getState().projects.find((project) => project.id === id);
|
||||
if (!baseline || !current) throw new Error("缺少画布同步基线,需要重新读取画布");
|
||||
const remote = patches.reduce(applyAgentCanvasPatch, baseline);
|
||||
const projected = patches.reduce(applyAgentCanvasPatch, current);
|
||||
if (projected !== current) {
|
||||
for (const listener of agentCanvasListeners) listener(projected, current);
|
||||
let remote = baseline;
|
||||
let projected = current;
|
||||
try {
|
||||
if (incrementalSession && !verifiedProjects.has(id) && baseline.remoteContentHash !== await canvasContentHash(baseline)) throw new Error("本地缓存尚未核对,请加载云端最新版本");
|
||||
if (current.revision !== baseline.revision || !Number.isSafeInteger(baseline.revision)) throw new Error("画布同步基线已变化");
|
||||
for (const patch of patches) {
|
||||
if (!Number.isSafeInteger(patch.revision) || !Number.isSafeInteger(patch.baseRevision)) throw new Error("Agent 增量缺少版本,请重新读取画布");
|
||||
if (patch.revision! <= remote.revision!) continue;
|
||||
if (patch.baseRevision !== remote.revision || patch.revision !== patch.baseRevision! + 1) throw new Error("Agent 画布增量版本不连续,请重新读取画布");
|
||||
remote = { ...applyAgentCanvasPatch(remote, patch), revision: patch.revision };
|
||||
projected = { ...applyAgentCanvasPatch(projected, patch), revision: patch.revision };
|
||||
}
|
||||
if (projected === current) return current;
|
||||
const hash = await canvasContentHash(remote);
|
||||
if (epoch !== sessionEpoch || useCanvasStore.getState().openProject(id) !== current) throw new Error("画布仍在更新,请重新同步 Agent 结果");
|
||||
remote = { ...remote, remoteContentHash: hash };
|
||||
projected = { ...projected, remoteContentHash: hash };
|
||||
if (!sameCanvasContent(projected, current)) {
|
||||
for (const listener of agentCanvasListeners) listener(projected, current);
|
||||
}
|
||||
} catch (error) {
|
||||
if (epoch === sessionEpoch) await preserveAgentConflict(useCanvasStore.getState().openProject(id) || current);
|
||||
throw error;
|
||||
}
|
||||
acknowledgedProjects.set(id, remote);
|
||||
verifiedProjects.add(id);
|
||||
if (projected === current) return current;
|
||||
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project.id === id ? projected : project) }));
|
||||
await flushCanvasStorePersistence();
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: sameCanvasContent(remote, projected) ? "done" : "pending", message: "已同步 Agent 画布结果" });
|
||||
return projected;
|
||||
});
|
||||
}
|
||||
|
||||
async function preserveAgentConflict(project: CanvasProject) {
|
||||
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "conflict", message: "云端画布已有更新,请保留草稿并加载最新版本" });
|
||||
const draftCount = await preserveCanvasSyncDraft(project);
|
||||
useSyncProgressStore.getState().setProjectProgress(project.id, { draftCount });
|
||||
}
|
||||
|
||||
async function waitForRemoteProjectLoads() {
|
||||
const pending = [...remoteProjectLoadPromises.values()];
|
||||
if (pending.length) await Promise.all(pending);
|
||||
@@ -184,7 +284,6 @@ const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audi
|
||||
export async function syncRemoteUserData(userId?: string | null) {
|
||||
// 登录/切换账号时,服务端快照建立新的远端基线;本地 IndexedDB 只负责首屏缓存,
|
||||
// 不能把服务端已经删除或当前用户无权访问的实体重新补回去。后续增量保存必须基于这份基线做冲突校验。
|
||||
let repairedCanvasAssets = false;
|
||||
await withRemoteUserDataSyncExclusive(async () => {
|
||||
incrementalSession = false;
|
||||
activeRemoteUserId = userId || "";
|
||||
@@ -199,30 +298,49 @@ export async function syncRemoteUserData(userId?: string | null) {
|
||||
// 登录只拉一次聚合快照。摘要列表再逐条请求详情会把 N 条数据放大成 2N+2 个请求,
|
||||
// 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。
|
||||
const snapshot = await getRemoteUserDataSnapshot();
|
||||
// 登录时服务端是实体真相。浏览器 IndexedDB 只作为首屏缓存,不能把服务端已删除的记录补回去。
|
||||
// 这里只替换结构化记录,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。
|
||||
const snapshotAssets = parseAssetRecordList(snapshot.assets);
|
||||
useCanvasStore.getState().replaceProjects(snapshot.projects);
|
||||
useAssetStore.getState().replaceAssets(snapshotAssets);
|
||||
const repair = repairMissingCanvasAssets();
|
||||
repairedCanvasAssets = repair.createdAssets > 0 || repair.updatedProjects > 0;
|
||||
const localProjects = useCanvasStore.getState().projects;
|
||||
const remoteById = new Map(snapshot.projects.map((project) => [project.id, project]));
|
||||
// Archive unsynced work before replacing the active cache, including deleted remote canvases.
|
||||
for (const local of localProjects) {
|
||||
const clean = local.remoteContentHash && local.remoteContentHash === await canvasContentHash(local);
|
||||
if (!clean && !sameCanvasContent(local, remoteById.get(local.id))) await preserveCanvasSyncDraft(local);
|
||||
}
|
||||
const projects = await Promise.all(snapshot.projects.map(async (project) => {
|
||||
const local = localProjects.find((item) => item.id === project.id);
|
||||
const draftCount = (await readCanvasSyncDrafts(project.id)).length;
|
||||
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "done", draftCount, message: "已保存到云端" });
|
||||
return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: await canvasContentHash(project) };
|
||||
}));
|
||||
if (useCanvasStore.getState().projects !== localProjects) throw new Error("本地画布仍在更新,已保留本地内容,请重新同步");
|
||||
useCanvasStore.getState().replaceProjects(projects);
|
||||
useAssetStore.getState().replaceAssets(parseAssetRecordList(snapshot.assets));
|
||||
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
|
||||
acknowledgedProjects = new Map(snapshot.projects.map((project) => [project.id, project]));
|
||||
acknowledgedAssets = new Map(snapshotAssets.map((asset) => [asset.id, asset]));
|
||||
acknowledgedProjects = new Map(projects.map((project) => [project.id, project]));
|
||||
acknowledgedAssets = new Map(parseAssetRecordList(snapshot.assets).map((asset) => [asset.id, asset]));
|
||||
remoteUserDataPhase = "ready";
|
||||
} catch (error) {
|
||||
remoteUserDataPhase = "failed";
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
if (repairedCanvasAssets) await saveRemoteUserDataNow();
|
||||
}
|
||||
|
||||
export function installRemoteUserDataAutoSync() {
|
||||
if (subscriptionsInstalled) return;
|
||||
subscriptionsInstalled = true;
|
||||
useCanvasStore.subscribe((state, previous) => {
|
||||
if (state.projects !== previous.projects) scheduleRemoteUserDataSync();
|
||||
if (state.projects === previous.projects) return;
|
||||
const before = new Map(previous.projects.map((project) => [project.id, project]));
|
||||
const changed = state.projects.filter((project) => !sameCanvasContent(before.get(project.id), project));
|
||||
if (!changed.length && state.projects.length === previous.projects.length) return;
|
||||
if (remoteUserDataPhase === "ready") {
|
||||
for (const project of changed) {
|
||||
if (sameCanvasContent(acknowledgedProjects.get(project.id), project)) continue;
|
||||
if (useSyncProgressStore.getState().syncingProjects[project.id]?.phase === "conflict") continue;
|
||||
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "pending", message: "有修改等待保存" });
|
||||
}
|
||||
}
|
||||
scheduleRemoteUserDataSync();
|
||||
});
|
||||
useAssetStore.subscribe((state, previous) => {
|
||||
if (state.assets !== previous.assets) scheduleRemoteUserDataSync();
|
||||
@@ -281,6 +399,9 @@ export function scheduleRemoteUserDataSync() {
|
||||
|
||||
export function formatLocalSavedRemotePending(localAction: string, error: unknown): string {
|
||||
const detail = error instanceof Error && error.message.trim() ? error.message.trim() : "未知错误";
|
||||
if (error instanceof ApiError && (error.status === 409 || error.status === 428)) {
|
||||
return `${localAction},云端同步已暂停:${detail}。请保留草稿并加载最新版本。`;
|
||||
}
|
||||
return `${localAction},云端同步失败:${detail}。将自动重试。`;
|
||||
}
|
||||
|
||||
@@ -295,7 +416,7 @@ export async function createCanvasProjectWithRemoteSync(title: string, projectId
|
||||
if (initialContent) useCanvasStore.getState().updateProject(id, initialContent);
|
||||
if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") };
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
await saveRemoteUserDataNow(id);
|
||||
return { id };
|
||||
} catch (syncError) {
|
||||
scheduleRemoteUserDataSync();
|
||||
@@ -436,35 +557,44 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveRemoteUserDataNow(options: { force?: boolean } = {}) {
|
||||
// 这是远端写入的总闸门:只有 phase=ready 且已建立 acknowledged 基线时才能提交。
|
||||
// 本地 Zustand/localForage 写成功不等于服务端写成功,任何同步异常都必须继续抛出给调用方。
|
||||
export async function saveRemoteUserDataNow(input?: string | readonly string[] | { force?: boolean }) {
|
||||
const projectId = typeof input === "string" || Array.isArray(input) ? input as string | readonly string[] : undefined;
|
||||
const options = input && typeof input === "object" && !Array.isArray(input) ? input as { force?: boolean } : {};
|
||||
const epoch = sessionEpoch;
|
||||
if (!activeRemoteUserId) return;
|
||||
if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,本地内容尚未保存到云端");
|
||||
requireRemoteUserDataBaseline();
|
||||
// 画布先用本地缓存秒开时,远端详情校验可能仍在进行;写入必须等待校验结果。
|
||||
const assertNoConflict = () => {
|
||||
const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id)
|
||||
: typeof projectId === "string" ? [projectId] : projectId;
|
||||
if (ids.some((id) => useSyncProgressStore.getState().syncingProjects[id]?.phase === "conflict")) {
|
||||
throw new ApiError("云端画布已有更新,请保留本地草稿并加载最新版本", { status: 409 });
|
||||
}
|
||||
};
|
||||
if (projectId !== undefined) assertNoConflict();
|
||||
await waitForRemoteProjectLoads();
|
||||
if (syncPromise) {
|
||||
syncQueued = true;
|
||||
return syncPromise;
|
||||
await syncPromise;
|
||||
assertNoConflict();
|
||||
return;
|
||||
}
|
||||
syncPromise = withRemoteUserDataSyncExclusive(async () => {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
|
||||
requireRemoteUserDataBaseline();
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
|
||||
await drainRemoteUserDataChanges(options);
|
||||
});
|
||||
try {
|
||||
await syncPromise;
|
||||
assertNoConflict();
|
||||
} finally {
|
||||
syncPromise = null;
|
||||
if (syncQueued) scheduleRemoteUserDataSync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制覆盖保存:以本地内容为准修复画布媒体与素材的绑定(重绑到引用同一资源的素材,
|
||||
* 缺失则按节点新建),再走「素材先于画布」的整体推送覆盖云端。服务端画布不变式只认
|
||||
* 「节点资源被其 assetId 对应素材引用」,重绑后的本地画布可以在不破坏该不变式的前提下
|
||||
* 覆盖远端。素材远端版本冲突时采纳远端为基线继续覆盖,避免显式覆盖被冲突检测卡死。
|
||||
* 修复媒体与素材的绑定,再按素材先于画布的顺序保存。
|
||||
* 素材修复可采用远端素材基线;画布始终携带原始 revision,不能绕过版本校验。
|
||||
*/
|
||||
export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebindResult> {
|
||||
const epoch = sessionEpoch;
|
||||
@@ -472,7 +602,7 @@ export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebin
|
||||
requireRemoteUserDataBaseline();
|
||||
await waitForRemoteProjectLoads();
|
||||
const rebind = await withRemoteUserDataSyncExclusive(async () => {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止强制覆盖");
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
|
||||
requireRemoteUserDataBaseline();
|
||||
const projects = useCanvasStore.getState().projects;
|
||||
// 服务端素材记录是 guard 实际校验的事实;本地缓存可能落后,须先取回再判定绑定一致性。
|
||||
@@ -503,26 +633,15 @@ async function drainRemoteUserDataChanges(options: { force?: boolean } = {}) {
|
||||
async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: { force?: boolean } = {}) {
|
||||
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
|
||||
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
|
||||
const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project)).map((project) => project.id));
|
||||
repairMissingCanvasAssets(incrementalSession ? changedProjectIds : undefined, incrementalSession);
|
||||
const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project)).map((project) => project.id));
|
||||
repairMissingCanvasAssets(changedProjectIds, incrementalSession);
|
||||
const currentProjects = useCanvasStore.getState().projects;
|
||||
const currentAssets = useAssetStore.getState().assets;
|
||||
const dirtyProjects = currentProjects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project));
|
||||
const dirtyProjects = currentProjects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project) && useSyncProgressStore.getState().syncingProjects[project.id]?.phase !== "conflict");
|
||||
const dirtyAssets = currentAssets.filter((asset) => !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset));
|
||||
if (!dirtyProjects.length && !dirtyAssets.length) return;
|
||||
|
||||
if (incrementalSession) {
|
||||
for (const source of dirtyProjects) {
|
||||
const baseline = acknowledgedProjects.get(source.id);
|
||||
if (!baseline || verifiedProjects.has(source.id)) continue;
|
||||
const { project } = await getRemoteCanvasProject(source.id);
|
||||
if (Date.parse(project.updatedAt) !== Date.parse(baseline.updatedAt)) {
|
||||
// 以远端当前版本作为新的校验基线,继续提交当前打开画布的完整快照。
|
||||
// 这是画布编辑态的显式覆盖策略,避免资产同步被旧缓存冲突永久阻塞。
|
||||
acknowledgedProjects.set(source.id, project);
|
||||
}
|
||||
verifiedProjects.add(source.id);
|
||||
}
|
||||
for (const source of dirtyAssets) {
|
||||
const baseline = acknowledgedAssets.get(source.id);
|
||||
if (!baseline || verifiedAssets.has(source.id)) continue;
|
||||
@@ -546,24 +665,27 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
|
||||
acknowledgedAssets.set(source.id, source);
|
||||
verifiedAssets.add(source.id);
|
||||
}
|
||||
const errors: unknown[] = [];
|
||||
for (const source of dirtyProjects) {
|
||||
const keysToUpload = collectLocalMediaKeys(source);
|
||||
const total = keysToUpload.length;
|
||||
if (total > 0) {
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||||
projectId: source.id,
|
||||
total,
|
||||
completed: 0,
|
||||
phase: "uploading",
|
||||
message: "正在同步媒体至云端",
|
||||
phase: total > 0 ? "uploading" : "saving",
|
||||
message: total > 0 ? "正在同步媒体至云端" : "正在保存画布",
|
||||
});
|
||||
}
|
||||
const onMediaUploaded = () => {
|
||||
if (total > 0) {
|
||||
useSyncProgressStore.getState().incrementProjectCompleted(source.id);
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (!Number.isSafeInteger(source.revision) || source.revision! < 0) {
|
||||
throw new ApiError("缺少画布版本,请保留草稿并加载云端最新版本", { status: 428 });
|
||||
}
|
||||
const hash = await canvasContentHash(source);
|
||||
const remotePayload = await ensureRemoteResourceReferences(source, uploaded, onMediaUploaded);
|
||||
if (total > 0) {
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||||
@@ -571,20 +693,45 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
|
||||
message: "正在保存画布结构",
|
||||
});
|
||||
}
|
||||
await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload));
|
||||
acknowledgedProjects.set(source.id, source);
|
||||
verifiedProjects.add(source.id);
|
||||
if (total > 0) useSyncProgressStore.getState().setProjectProgress(source.id, null);
|
||||
} catch (error) {
|
||||
if (total > 0) {
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||||
phase: "error",
|
||||
message: error instanceof Error ? error.message : "云端同步失败,等待重试",
|
||||
});
|
||||
const { project: saved } = await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload));
|
||||
if (!Number.isSafeInteger(saved.revision) || saved.revision !== source.revision! + 1) {
|
||||
throw new ApiError("服务端未返回有效画布版本,请加载云端最新版本", { status: 409 });
|
||||
}
|
||||
throw error;
|
||||
const current = useCanvasStore.getState().openProject(source.id);
|
||||
if (current && current.revision !== source.revision) {
|
||||
throw new ApiError("画布基线已变化,请加载云端最新版本", { status: 409 });
|
||||
}
|
||||
const acknowledged = { ...source, revision: saved.revision, remoteContentHash: hash };
|
||||
acknowledgedProjects.set(source.id, acknowledged);
|
||||
verifiedProjects.add(source.id);
|
||||
if (current) {
|
||||
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project === current
|
||||
? { ...project, revision: saved.revision, remoteContentHash: hash } : project) }));
|
||||
}
|
||||
await flushCanvasStorePersistence();
|
||||
const pending = !sameCanvasContent(source, useCanvasStore.getState().openProject(source.id) || undefined);
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: pending ? "pending" : "done", message: pending ? "有新修改等待保存" : "已保存到云端" });
|
||||
if (pending) syncQueued = true;
|
||||
} catch (error) {
|
||||
const conflict = error instanceof ApiError && (error.status === 409 || error.status === 428);
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||||
phase: conflict ? "conflict" : "error",
|
||||
message: error instanceof Error ? error.message : "云端同步失败,等待重试",
|
||||
});
|
||||
if (conflict) {
|
||||
try {
|
||||
const current = useCanvasStore.getState().openProject(source.id) || source;
|
||||
const draftCount = await preserveCanvasSyncDraft(current);
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, { draftCount });
|
||||
} catch (draftError) {
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, { message: "本地草稿保存失败,请勿关闭页面;请先下载草稿" });
|
||||
errors.push(draftError);
|
||||
}
|
||||
}
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (errors.length) throw errors[0];
|
||||
if (dirtyProjects.length) void appQueryClient.invalidateQueries({ queryKey: ["canvas-library"] });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user