diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 22ddb9783..7ef2ea7a2 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -3019,3 +3019,10 @@ SQLite 与 HTTP recorder 已验证;PostgreSQL 16/17、前端构建及浏览 - [ ] 窄屏(≤1100px、≤640px)下确认画廊卡片不被裁切、灵感页分页与筛选可用,并检查明暗主题。 - [ ] 文本创作类型目前只有 9 条原创条目,画廊池暂无文本条目,仍会重复出现,待补数据。 - [ ] 外部来源(awesome-seedance、好好学 AI、YouMind)的署名与许可声明尚未在页面展示,待定。 + +## 节点补丁字段基线 + +- 已修复:节点可选字段的 undefined 在 JSON 操作请求中转换为 null,避免基线或删除值被丢弃;缺省与 undefined 的变化不提交。旧队列只使用原始快照恢复无效补丁,保留 mutationId,不使用当前云端值替代基线。 +- [x] 专项测试覆盖新增/删除可选字段的真实 JSON 序列化、旧空补丁恢复、有效请求不变、缺少原快照不猜测以及无效变化不发送。 +- [x] 用户确认原先 Agent 发消息前“节点补丁缺少字段基线”的场景恢复正常。 +- [ ] 在多标签页同时修改字段并重试旧队列,确认服务端仍拒绝同字段冲突;本次未修改服务端并发校验。 diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index a587f53d4..d9ff91dbe 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -1,16 +1,6 @@ import { canonicalize } from "json-canonicalize"; import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; -import { - deleteRemoteAssets, - deleteRemoteCanvasProject, - applyRemoteCanvasOperations, - getRemoteAsset, - getRemoteAssetsByIds, - getRemoteCanvasProject, - getRemoteUserDataSnapshot, - listRemoteAssetsPage, - upsertRemoteAsset, -} from "@/services/api/user-data"; +import { deleteRemoteAssets, deleteRemoteCanvasProject, applyRemoteCanvasOperations, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, upsertRemoteAsset } from "@/services/api/user-data"; import type { CanvasOperation, CanvasOperationsRequest } from "@/services/api/user-data"; import { ApiError } from "@/services/api/request"; import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content"; @@ -91,6 +81,7 @@ async function loadPendingCanvasMutationJournal(scope = getActiveUserScope()) { * 1. 新画布请求在 create_canvas.patch 之后又追加了同字段 set_field(beforeValue 缺失或为 null), * 服务端先创建字段再校验基线,必然冲突;这些 set_field 与 patch 重复,直接移除。 * 2. 已有画布的 set_field 因 undefined 被 JSON 丢弃属性,补回 JSON null 还原原始意图。 + * 3. 节点补丁的 undefined 被序列化丢弃,造成空 patch/before;只从 journal 原始快照恢复,不能使用当前云端值替代基线。 * 服务端校验失败会整笔回滚、不写 receipt,因此沿用原 mutationId 不会触发 canvas_mutation_id_reused。 */ export function repairPendingCanvasMutationRequest(request: CanvasOperationsRequest, baseline?: CanvasProject, prepared?: CanvasProject): CanvasOperationsRequest { @@ -108,18 +99,32 @@ export function repairPendingCanvasMutationRequest(request: CanvasOperationsRequ changed = true; return false; }); - const operations = kept.map((operation) => { - if (operation.type !== "set_field") return operation; - const hasBefore = Object.prototype.hasOwnProperty.call(operation, "beforeValue") && operation.beforeValue !== undefined; - const hasValue = Object.prototype.hasOwnProperty.call(operation, "value") && operation.value !== undefined; - if (hasBefore && hasValue) return operation; - changed = true; - return { - ...operation, - value: hasValue ? operation.value : canvasOperationValue(prepared?.[operation.field]), - beforeValue: hasBefore ? operation.beforeValue : canvasOperationValue(baseline?.[operation.field]), - }; - }); + const operations = kept + .map((operation) => { + if (operation.type === "patch_node" && (!Object.keys(operation.patch).length || !Object.keys(operation.before || {}).length)) { + const beforeNode = baseline?.nodes.find((node) => node.id === operation.id); + const afterNode = prepared?.nodes.find((node) => node.id === operation.id); + // 空补丁或空基线必然被服务端拒绝且没有成功回执,沿用原 mutationId 安全重试。 + // 缺少原快照时不猜测旧值;有效请求不重新生成,以免改变已确认请求的摘要。 + if (beforeNode && afterNode) { + const diff = diffCanvasObject(beforeNode as unknown as Record, afterNode as unknown as Record); + changed = true; + if (!Object.keys(diff.patch).length) return undefined; + return { ...operation, patch: diff.patch, before: diff.previous }; + } + } + if (operation.type !== "set_field") return operation; + const hasBefore = Object.prototype.hasOwnProperty.call(operation, "beforeValue") && operation.beforeValue !== undefined; + const hasValue = Object.prototype.hasOwnProperty.call(operation, "value") && operation.value !== undefined; + if (hasBefore && hasValue) return operation; + changed = true; + return { + ...operation, + value: hasValue ? operation.value : canvasOperationValue(prepared?.[operation.field]), + beforeValue: hasBefore ? operation.beforeValue : canvasOperationValue(baseline?.[operation.field]), + }; + }) + .filter((operation): operation is CanvasOperation => operation !== undefined); return changed ? { ...request, operations } : request; } @@ -480,13 +485,13 @@ export async function syncRemoteUserData(userId?: string | null) { const local = localProjects.find((item) => item.id === project.id); const remoteContentHash = await canvasContentHash(project); const pending = pendingCanvasMutations.has(project.id); - useSyncProgressStore.getState().setProjectProgress(project.id, { phase: pending ? "pending" : "done", cloudRevision: project.revision, pendingChanges: pending ? 1 : 0, message: pending ? "保留未确认画布操作,等待重放" : "已保存到云端" }); + useSyncProgressStore + .getState() + .setProjectProgress(project.id, { phase: pending ? "pending" : "done", cloudRevision: project.revision, pendingChanges: pending ? 1 : 0, message: pending ? "保留未确认画布操作,等待重放" : "已保存到云端" }); return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash }; }), ); - const orphanProjects = localProjects - .filter((project) => remoteDeletedProjects.has(project.id)) - .map((project) => ({ ...project, remoteContentHash: project.remoteContentHash })); + const orphanProjects = localProjects.filter((project) => remoteDeletedProjects.has(project.id)).map((project) => ({ ...project, remoteContentHash: project.remoteContentHash })); // A revision-0 project can be absent from the cloud snapshot because its // create mutation has not been acknowledged yet. Keep it visible so the // pending journal can replay against the next session. @@ -884,9 +889,11 @@ function diffCanvasObject(before: Record, after: Record, options: { // 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。 // 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。 const projectsBeforeRepair = useCanvasStore.getState().projects; - const selectedProjectIds = options.projectId === undefined - ? undefined - : new Set(typeof options.projectId === "string" ? [options.projectId] : options.projectId); - const syncProjectIds = selectedProjectIds === undefined - ? undefined - : new Set([...selectedProjectIds].filter((id) => projectsBeforeRepair.some((project) => project.id === id))); + const selectedProjectIds = options.projectId === undefined ? undefined : new Set(typeof options.projectId === "string" ? [options.projectId] : options.projectId); + const syncProjectIds = selectedProjectIds === undefined ? undefined : new Set([...selectedProjectIds].filter((id) => projectsBeforeRepair.some((project) => project.id === id))); const changedProjectIds = new Set( projectsBeforeRepair .filter((project) => !remoteDeletedProjects.has(project.id) && (syncProjectIds === undefined || syncProjectIds.has(project.id)) && (pendingCanvasMutations.has(project.id) || !sameCanvasContent(acknowledgedProjects.get(project.id), project))) @@ -1031,11 +1034,12 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { const url = resourceFileUrl(resourceId); const displayUrl = result.kind === "image" ? result.data.dataUrl : result.data.url; const coverUrl = result.coverUrl === displayUrl ? (result.kind === "image" ? url : "") : result.coverUrl; - result = result.kind === "image" - ? { ...result, coverUrl, data: { ...result.data, dataUrl: url } } - : result.kind === "video" - ? { ...result, coverUrl, data: { ...result.data, url } } - : { ...result, coverUrl, data: { ...result.data, url } }; + result = + result.kind === "image" + ? { ...result, coverUrl, data: { ...result.data, dataUrl: url } } + : result.kind === "video" + ? { ...result, coverUrl, data: { ...result.data, url } } + : { ...result, coverUrl, data: { ...result.data, url } }; } } return result; @@ -1076,7 +1080,7 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { merged = { ...merged, updatedAt: source.updatedAt }; mergedAssets.set(source.id, merged); } - useAssetStore.setState((state) => ({ assets: state.assets.map((item) => item.id === source.id ? merged : item) })); + useAssetStore.setState((state) => ({ assets: state.assets.map((item) => (item.id === source.id ? merged : item)) })); } } @@ -1130,9 +1134,19 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { let savedRevision: number; if (pendingJournal) { preparedForMutation = pendingJournal.prepared; + const repairedRequest = repairPendingCanvasMutationRequest(pendingJournal.request, pendingJournal.baseline, pendingJournal.prepared); + if (repairedRequest !== pendingJournal.request) { + pendingJournal.request = repairedRequest; + await persistPendingCanvasMutationJournal(); + } useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "saving", message: "正在重放未确认的画布操作" }); - const response = await applyRemoteCanvasOperations(source.id, pendingJournal.request); - savedRevision = Number(response.project.revision); + // 旧队列可能只记录了 undefined 与缺省值之间的无效变化,没有需要发送的修改。 + if (pendingJournal.request.operations.length) { + const response = await applyRemoteCanvasOperations(source.id, pendingJournal.request); + savedRevision = Number(response.project.revision); + } else { + savedRevision = Number(current.revision); + } pendingCanvasMutations.delete(source.id); await persistPendingCanvasMutationJournal(); } else { @@ -1164,9 +1178,9 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { acknowledgedProjects.set(source.id, acknowledged); verifiedProjects.add(source.id); if (sameCanvasContent(latest, preparedForMutation)) { - useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project === latest ? acknowledged : project) })); + useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project === latest ? acknowledged : project)) })); } else { - useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project === latest ? { ...project, revision: remote.revision, remoteContentHash: remoteHash } : project) })); + useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project === latest ? { ...project, revision: remote.revision, remoteContentHash: remoteHash } : project)) })); } await flushCanvasStorePersistence(); const currentAfterSave = useCanvasStore.getState().openProject(source.id); @@ -1176,7 +1190,7 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { cloudRevision: Number(remote.revision), pendingChanges: pending ? 1 : 0, errorKind: undefined, - message: pending ? "有新修改等待保存" : "已保存到云端", + message: pending ? "有新修改等待保存" : "已保存到云端", }); if (pending) syncQueued = true; } catch (error) { @@ -1185,7 +1199,7 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "error", errorKind, - pendingChanges: 1, + pendingChanges: 1, message: error instanceof Error ? error.message : "云端同步失败,等待自动恢复", }); errors.push(error); diff --git a/web/test/canvas-operations-build.test.ts b/web/test/canvas-operations-build.test.ts index fbf1fb6c7..9bd18a8d3 100644 --- a/web/test/canvas-operations-build.test.ts +++ b/web/test/canvas-operations-build.test.ts @@ -22,6 +22,31 @@ function canvas(patch: Partial = {}): CanvasProject { } describe("画布 operations 生成", () => { + test("节点可选字段的 undefined 在 JSON 请求中保留为 null 基线和删除值", () => { + const node = { id: "node", metadata: { title: undefined } } as unknown as CanvasProject["nodes"][number]; + const changed = { ...node, metadata: { title: "新标题" } }; + const added = JSON.parse(JSON.stringify(buildCanvasOperations(canvas({ nodes: [node] }), canvas({ nodes: [changed] })))); + expect(added).toEqual([{ type: "patch_node", id: "node", patch: { "metadata.title": "新标题" }, before: { "metadata.title": null } }]); + const removed = JSON.parse(JSON.stringify(buildCanvasOperations(canvas({ nodes: [changed] }), canvas({ nodes: [node] })))); + expect(removed).toEqual([{ type: "patch_node", id: "node", patch: { "metadata.title": null }, before: { "metadata.title": "新标题" } }]); + expect(buildCanvasOperations(canvas({ nodes: [node] }), canvas({ nodes: [{ ...node, metadata: {} }] }))).toEqual([]); + }); + + test("旧 journal 的空节点补丁从原快照恢复,保留 mutationId 和有效请求", () => { + const before = canvas({ nodes: [{ id: "node", metadata: {} }] as CanvasProject["nodes"] }); + const after = canvas({ nodes: [{ id: "node", metadata: { title: "新标题" } }] as CanvasProject["nodes"] }); + const broken = { mutationId: "node-mutation", operations: [{ type: "patch_node", id: "node", patch: { "metadata.title": "新标题" }, before: {} }] } as Parameters[0]; + const repaired = repairPendingCanvasMutationRequest(broken, before, after); + expect(repaired.mutationId).toBe("node-mutation"); + expect(repaired.operations).toEqual(buildCanvasOperations(before, after)); + expect(repairPendingCanvasMutationRequest(repaired, before, after)).toBe(repaired); + expect(repairPendingCanvasMutationRequest(broken)).toBe(broken); + + const deletion = { mutationId: "delete-field", operations: [{ type: "patch_node", id: "node", patch: {}, before: { "metadata.title": "新标题" } }] } as Parameters[0]; + expect(repairPendingCanvasMutationRequest(deletion, after, before).operations).toEqual(buildCanvasOperations(after, before)); + const noop = { ...broken, mutationId: "noop", operations: [{ ...broken.operations[0], patch: {} }] } as Parameters[0]; + expect(repairPendingCanvasMutationRequest(noop, before, before).operations).toEqual([]); + }); test("新画布只发 create_canvas,不追加会与创建字段冲突的 set_field", () => { const operations = buildCanvasOperations(undefined, canvas()); expect(operations[0]?.type).toBe("create_canvas");