feat(canvas): 条件读取与历史 - 减少画布同步传输并校准版本

This commit is contained in:
ddcat666
2026-09-29 08:26:29 +08:00
parent bcccddb4e9
commit 6ad1adc197
17 changed files with 783 additions and 25 deletions

View File

@@ -12,11 +12,30 @@ export function sameCanvasContent(left: CanvasProject | undefined, right: Canvas
if (!left || !right) return false;
const a = canvasContentSnapshot(left) as Record<string, unknown>;
const b = canvasContentSnapshot(right) as Record<string, unknown>;
return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((key) => a[key] === b[key] || canonicalize(a[key]) === canonicalize(b[key]));
return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((key) => {
if (a[key] === b[key]) return true;
if ((key === "nodes" || key === "connections") && Array.isArray(a[key]) && Array.isArray(b[key])) {
const leftItems = a[key] as unknown[];
const rightItems = b[key] as unknown[];
return leftItems.length === rightItems.length && leftItems.every((item, index) => item === rightItems[index] || canonicalValue(item) === canonicalValue(rightItems[index]));
}
return canonicalValue(a[key]) === canonicalValue(b[key]);
});
}
type ContentSnapshot = ReturnType<typeof canvasContentSnapshot>;
const contentHashes = new WeakMap<CanvasProject["nodes"], { content: ContentSnapshot; hash: Promise<string> }>();
// Canvas content trees are immutable, so unchanged node objects can reuse their serialized comparison value.
const canonicalValueCache = new WeakMap<object, string>();
function canonicalValue(value: unknown) {
if (!value || typeof value !== "object") return canonicalize(value);
const cached = canonicalValueCache.get(value);
if (cached) return cached;
const serialized = canonicalize(value);
canonicalValueCache.set(value, serialized);
return serialized;
}
// Store updates are immutable. Reuse a hash across viewport/revision-only copies;
// any content field/reference change invalidates it. Weak keys bound its lifetime.

View File

@@ -46,9 +46,10 @@ export const apiClient = axios.create({ baseURL: apiBaseURL, withCredentials: tr
* 取消请求必须继续抛出 AbortError,不能被包装成普通失败,否则页面切换会被误报为错误。
*/
export async function request<T>(promise: Promise<{ data: BackendEnvelope<T>; status?: number; headers?: unknown }>) {
export async function request<T>(promise: Promise<{ data: BackendEnvelope<T>; status?: number; headers?: unknown }>, options: { allowNotModified?: boolean } = {}) {
try {
const response = await promise;
if (response.status === 304 && options.allowNotModified) return { notModified: true } as T;
if (response.data.code !== 0) {
throw new ApiError(response.data.msg || "请求失败", {
status: response.status,
@@ -122,10 +123,13 @@ function retryAfterMilliseconds(headers: unknown) {
return Math.max(0, retryAt - Date.now());
}
export type HttpRequestConfig = Omit<AxiosRequestConfig, "method" | "url" | "data" | "baseURL">;
export type HttpRequestConfig = Omit<AxiosRequestConfig, "method" | "url" | "data" | "baseURL"> & {
allowNotModified?: boolean;
};
async function send<T>(method: string, url: string, data?: unknown, config?: HttpRequestConfig) {
return request<T>(apiClient.request<BackendEnvelope<T>>({ method, url, data, ...config }));
const { allowNotModified, ...axiosConfig } = config || {};
return request<T>(apiClient.request<BackendEnvelope<T>>({ method, url, data, ...axiosConfig }), { allowNotModified });
}
/**

View File

@@ -1,7 +1,6 @@
import type { Asset } from "@/stores/use-asset-store";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { http, compactApiParams } from "@/services/api/request";
import { compactApiParams, http } from "@/services/api/request";
export type RemoteUserDataSummary = {
id: string;
@@ -117,8 +116,18 @@ export function listRemoteCanvasProjects() {
return http.get<{ projects: RemoteUserDataSummary[] }>("/canvas-projects");
}
export function getRemoteCanvasProject(id: string) {
return http.get<{ project: CanvasProject }>(`/canvas-projects/${encodeURIComponent(id)}`);
export function getRemoteCanvasProject(id: string, knownProject?: CanvasProject) {
if (!knownProject || knownProject.id !== id || !Number.isSafeInteger(knownProject.revision)) {
return http.get<{ project: CanvasProject }>(`/canvas-projects/${encodeURIComponent(id)}`).then((result) => ({ ...result, notModified: false }));
}
const etag = `"canvas-${knownProject.revision}"`;
return http
.get<{ project: CanvasProject } | { notModified: true }>(`/canvas-projects/${encodeURIComponent(id)}`, {
headers: { "If-None-Match": etag },
validateStatus: (status) => (status >= 200 && status < 300) || status === 304,
allowNotModified: true,
})
.then((result) => ("notModified" in result ? { project: knownProject, notModified: true } : { ...result, notModified: false }));
}
export function upsertRemoteCanvasProject(project: CanvasProject, options?: { repairMissingResources?: boolean }) {

View File

@@ -89,7 +89,8 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest?
}
let remote: CanvasProject;
try {
remote = (await getRemoteCanvasProject(id)).project;
const knownRemote = verifiedProjects.has(id) ? acknowledgedProjects.get(id) : undefined;
remote = (await getRemoteCanvasProject(id, knownRemote)).project;
} catch (error) {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
const local = useCanvasStore.getState().openProject(id);
@@ -158,7 +159,8 @@ export async function refreshCanvasAfterAgent(id: string) {
const epoch = sessionEpoch;
return withRemoteUserDataSyncExclusive(async () => {
if (!activeRemoteUserId) throw new Error("请先登录再刷新 Agent 画布结果");
const { project } = await getRemoteCanvasProject(id);
const knownRemote = verifiedProjects.has(id) ? acknowledgedProjects.get(id) : undefined;
const { project } = await getRemoteCanvasProject(id, knownRemote);
if (epoch !== sessionEpoch) throw new Error("账号已切换");
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
const baseline = acknowledgedProjects.get(id);
@@ -823,6 +825,26 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
if (pending) syncQueued = true;
} catch (error) {
const conflict = error instanceof ApiError && error.reason !== "canvas_history_resources_missing" && (error.status === 409 || error.status === 428);
if (conflict) {
try {
const { project: remote } = await getRemoteCanvasProject(source.id);
const current = useCanvasStore.getState().openProject(source.id);
if (current && current === useCanvasStore.getState().openProject(source.id) && sameCanvasContent(current, remote)) {
const hash = await canvasContentHash(remote);
if (current === useCanvasStore.getState().openProject(source.id)) {
const reconciled = { ...remote, viewport: current.viewport, remoteContentHash: hash };
acknowledgedProjects.set(source.id, reconciled);
verifiedProjects.add(source.id);
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project.id === source.id ? reconciled : project) }));
await flushCanvasStorePersistence();
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "done", message: "云端内容一致,已自动校准版本" });
continue;
}
}
} catch {
// Keep the original conflict and preserve the local draft below.
}
}
useSyncProgressStore.getState().setProjectProgress(source.id, {
phase: conflict ? "conflict" : "error",
message: error instanceof Error ? error.message : "云端同步失败,等待重试",

View File

@@ -0,0 +1,31 @@
import { afterEach, describe, expect, test } from "bun:test";
import { getRemoteCanvasProject } from "../src/services/api/user-data";
import { apiClient } from "../src/services/api/request";
import type { CanvasProject } from "../src/stores/canvas/use-canvas-store";
const originalAdapter = apiClient.defaults.adapter;
afterEach(() => {
apiClient.defaults.adapter = originalAdapter;
});
describe("conditional canvas reads", () => {
test("reuses a verified project when the server returns 304", async () => {
const known = {
id: "canvas-1",
title: "Cached canvas",
revision: 4,
updatedAt: "2026-09-28T12:00:00.000Z",
} as CanvasProject;
let requestEtag = "";
apiClient.defaults.adapter = async (config) => {
const headers = config.headers as { get?: (name: string) => string | undefined; [key: string]: unknown } | undefined;
requestEtag = String(headers?.get?.("If-None-Match") ?? headers?.["If-None-Match"] ?? "");
return { config, status: 304, statusText: "Not Modified", headers: {}, data: null };
};
await expect(getRemoteCanvasProject(known.id, known)).resolves.toEqual({ project: known, notModified: true });
expect(requestEtag).toBe('"canvas-4"');
});
});

View File

@@ -232,6 +232,20 @@ test("load latest, save and reload stay synced when Agent history replays an unv
expect(requests.filter((request) => request.method === "put")).toHaveLength(1);
});
test("a stale save auto-reconciles when the cloud already contains the identical local content", async () => {
useCanvasStore.getState().renameProject("canvas", "same content saved elsewhere");
beforePut = async () => {
const current = useCanvasStore.getState().openProject("canvas")!;
remote.set("canvas", { ...structuredClone(current), revision: current.revision! + 1 });
};
await expect(saveRemoteUserDataNow("canvas")).resolves.toBeUndefined();
expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ title: "same content saved elsewhere", revision: 2 });
expect(useSyncProgressStore.getState().syncingProjects.canvas).toMatchObject({ phase: "done", message: "云端内容一致,已自动校准版本" });
expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0);
});
test("a conflict preserves drafts, stops retries and does not block another canvas", async () => {
remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 });
useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes });