fix(canvas): 画布 - 防止旧内容覆盖并支持版本历史恢复

This commit is contained in:
kinoko
2026-09-17 18:40:42 +08:00
parent b6bae4f957
commit 35ebed3097
69 changed files with 3575 additions and 294 deletions

View File

@@ -53,7 +53,7 @@ export function createAgentCanvasSync(options: Options) {
if (patch && patch.canvasId === options.canvasId) {
supportsPatches = true;
patches.push(patch);
} else if (!supportsPatches && (event.type === "canvas_updated" || event.type === "generation_task_created" || event.type === "tool_failed" || event.type === "run_failed" || (event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName))))) {
} else if (event.type === "canvas_updated" || event.type === "canvas_undone" || (!supportsPatches && (event.type === "generation_task_created" || event.type === "tool_failed" || event.type === "run_failed" || (event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName)))))) {
needsRefresh = true;
}
schedule();

View File

@@ -10,6 +10,7 @@ export type RemoteUserDataSummary = {
title: string;
createdAt: string;
updatedAt: string;
revision?: number;
};
export type AssetFolder = {
@@ -36,7 +37,7 @@ export type RemoteUserDataSnapshot = {
projects: CanvasProject[];
};
export type CanvasLibrarySummary = Pick<CanvasProject, "id" | "projectId" | "title" | "createdAt" | "updatedAt"> & {
export type CanvasLibrarySummary = Pick<CanvasProject, "id" | "projectId" | "title" | "revision" | "createdAt" | "updatedAt"> & {
nodeCount: number;
previewNodes: CanvasProject["nodes"];
};
@@ -117,9 +118,35 @@ export function getRemoteCanvasProject(id: string) {
}
export function upsertRemoteCanvasProject(project: CanvasProject) {
return http.put<{ project: RemoteUserDataSummary }>(`/canvas-projects/${encodeURIComponent(project.id)}`, { project });
const { viewport: _viewport, remoteContentHash: _hash, ...content } = project;
return http.put<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(project.id)}`, { project: content });
}
export function deleteRemoteCanvasProject(id: string) {
return http.delete<{ id: string }>(`/canvas-projects/${encodeURIComponent(id)}`);
}
export type CanvasHistoryEntry = {
id: string;
canvasId: string;
revision: number;
title: string;
nodeCount: number;
connectionCount: number;
payloadBytes: number;
reason: "automatic" | "before_restore";
createdAt: string;
contentUpdatedAt: string;
};
export function listCanvasHistory(id: string, signal?: AbortSignal) {
return http.get<{ snapshots: CanvasHistoryEntry[]; currentRevision: number }>(`/canvas-projects/${encodeURIComponent(id)}/history`, { signal });
}
export function getCanvasHistoryEntry(id: string, snapshotId: string, signal?: AbortSignal) {
return http.get<{ snapshot: CanvasHistoryEntry; project: CanvasProject }>(`/canvas-projects/${encodeURIComponent(id)}/history/${encodeURIComponent(snapshotId)}`, { signal });
}
export function restoreRemoteCanvasHistory(id: string, snapshotId: string, revision: number) {
return http.post<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(id)}/history/${encodeURIComponent(snapshotId)}/restore`, { revision });
}

View File

@@ -0,0 +1,40 @@
import { nanoid } from "nanoid";
import { localForageStorageForScope } from "@/lib/localforage-storage";
import { getActiveUserScope } from "@/lib/user-scope";
import { sameCanvasContent } from "@/lib/canvas/canvas-content";
import { withCanvasStorePersistenceLock, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository";
export type CanvasSyncDraft = { id: string; savedAt: string; project: CanvasProject };
const draftKey = (projectId: string) => `infinite-canvas:sync-drafts:${projectId}`;
const draftIndexKey = "infinite-canvas:sync-draft-index";
export async function readAllCanvasSyncDrafts(scope = getActiveUserScope()) {
const raw = await localForageStorageForScope(scope).getItem(draftIndexKey);
const ids: string[] = raw ? JSON.parse(raw) : [];
return (await Promise.all(ids.map((id) => readCanvasSyncDrafts(id, scope)))).flat();
}
export async function readCanvasSyncDrafts(projectId: string, scope = getActiveUserScope()): Promise<CanvasSyncDraft[]> {
const raw = await localForageStorageForScope(scope).getItem(draftKey(projectId));
return raw ? JSON.parse(raw) : [];
}
export async function preserveCanvasSyncDraft(project: CanvasProject, scope = getActiveUserScope()) {
// A separate record survives replacement of the active canvas and a browser reload.
const snapshot = structuredClone(project);
return withGenerationArtifactCommitLock(scope, () =>
withCanvasStorePersistenceLock(scope, async () => {
const drafts = await readCanvasSyncDrafts(snapshot.id, scope);
if (!drafts.some((draft) => sameCanvasContent(draft.project, snapshot))) {
const storage = localForageStorageForScope(scope);
const rawIndex = await storage.getItem(draftIndexKey);
const ids: string[] = rawIndex ? JSON.parse(rawIndex) : [];
if (!ids.includes(snapshot.id)) await storage.setItem(draftIndexKey, JSON.stringify([...ids, snapshot.id]));
drafts.push({ id: nanoid(), savedAt: new Date().toISOString(), project: snapshot });
await storage.setItem(draftKey(snapshot.id), JSON.stringify(drafts));
}
return drafts.length;
}),
);
}

View File

@@ -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"] });
}