From 35ebed30974a642511333f528de3cffba44cdcac Mon Sep 17 00:00:00 2001 From: kinoko <1771634408@qq.com> Date: Thu, 17 Sep 2026 18:40:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E7=94=BB=E5=B8=83=20-=20?= =?UTF-8?q?=E9=98=B2=E6=AD=A2=E6=97=A7=E5=86=85=E5=AE=B9=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E7=89=88=E6=9C=AC=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/migrate-sqlite-postgres/main.go | 12 +- .../cmd/migrate-sqlite-postgres/main_test.go | 29 ++ backend/internal/app/admin_storage_delete.go | 2 +- backend/internal/app/canvas_bridge.go | 49 +- .../app/canvas_history_resource_test.go | 68 +++ .../internal/app/cloud_agent_canvas_events.go | 31 +- .../app/cloud_agent_canvas_events_test.go | 44 ++ backend/internal/app/cloud_agent_media.go | 2 +- .../app/cloud_agent_storyboard_test.go | 2 +- backend/internal/app/cloud_agent_undo.go | 2 +- backend/internal/app/cloud_agent_undo_test.go | 4 +- backend/internal/app/creation_canvas.go | 2 +- backend/internal/app/project.go | 2 +- backend/internal/app/project_delete_test.go | 2 + backend/internal/app/project_settings_test.go | 1 + backend/internal/app/resource_delete.go | 11 + backend/internal/assets/references.go | 2 +- backend/internal/canvas/canvas_history.go | 185 ++++++++ .../internal/canvas/canvas_history_test.go | 96 ++++ .../internal/canvas/canvas_revision_test.go | 77 ++++ backend/internal/canvas/user_data.go | 138 +++--- backend/internal/canvas/user_data_page.go | 3 +- backend/internal/database/migrations.go | 5 +- backend/internal/database/migrations_test.go | 40 +- backend/internal/database/schema.go | 2 + backend/internal/handler/openapi.yaml | 106 +++++ backend/internal/handler/user_data.go | 71 +++ .../internal/model/models_canvas_history.go | 25 + backend/internal/model/models_project.go | 1 + .../repository/admin_storage_delete.go | 3 + backend/internal/repository/canvas_history.go | 152 +++++++ .../repository/canvas_history_test.go | 151 ++++++ .../repository/canvas_revision_test.go | 159 +++++++ backend/internal/repository/creation.go | 12 +- backend/internal/repository/repository.go | 74 ++- .../internal/repository/resource_reference.go | 8 + backend/internal/service/aliases_types.go | 2 +- .../content/docs/backend/backend-database.mdx | 16 +- docs/content/docs/backend/code-map.mdx | 2 + docs/content/docs/backend/http-api.mdx | 17 + docs/content/docs/overview/features.mdx | 10 + docs/content/docs/progress/pending-test.mdx | 12 + docs/public/images/canvas-version-history.png | Bin 0 -> 84596 bytes .../canvas/canvas-focus-mode-bar.tsx | 14 +- web/src/components/canvas/infinite-canvas.tsx | 37 +- web/src/lib/canvas/agent-canvas-patch.ts | 18 +- web/src/lib/canvas/canvas-content.ts | 26 ++ web/src/lib/canvas/canvas-export.ts | 4 +- web/src/lib/canvas/canvas-storage-revision.ts | 7 + .../pages/canvas/canvas-project-top-bar.tsx | 29 +- web/src/pages/canvas/canvas-sync-status.tsx | 137 ++++++ .../pages/canvas/canvas-version-history.css | 250 ++++++++++ .../pages/canvas/canvas-version-history.tsx | 429 ++++++++++++++++++ .../pages/canvas/canvas-version-preview.tsx | 230 ++++++++++ web/src/pages/canvas/index.tsx | 12 +- web/src/pages/canvas/project.tsx | 60 ++- web/src/pages/canvas/use-canvas-keyboard.ts | 5 +- .../canvas/use-canvas-project-lifecycle.ts | 111 ++++- web/src/services/agent-canvas-sync.ts | 2 +- web/src/services/api/user-data.ts | 31 +- web/src/services/canvas-sync-drafts.ts | 40 ++ web/src/services/user-data-sync.ts | 315 +++++++++---- web/src/stores/canvas/use-canvas-store.ts | 28 +- web/src/stores/use-asset-store.ts | 4 +- web/src/stores/use-sync-progress-store.ts | 5 +- web/test/agent-canvas-patch.test.ts | 10 + web/test/agent-canvas-refresh.test.ts | 87 +++- web/test/agent-canvas-sync.test.ts | 11 + web/test/canvas-remote-revision.test.ts | 335 ++++++++++++++ 69 files changed, 3575 insertions(+), 294 deletions(-) create mode 100644 backend/internal/app/canvas_history_resource_test.go create mode 100644 backend/internal/canvas/canvas_history.go create mode 100644 backend/internal/canvas/canvas_history_test.go create mode 100644 backend/internal/canvas/canvas_revision_test.go create mode 100644 backend/internal/model/models_canvas_history.go create mode 100644 backend/internal/repository/canvas_history.go create mode 100644 backend/internal/repository/canvas_history_test.go create mode 100644 backend/internal/repository/canvas_revision_test.go create mode 100644 docs/public/images/canvas-version-history.png create mode 100644 web/src/lib/canvas/canvas-content.ts create mode 100644 web/src/pages/canvas/canvas-sync-status.tsx create mode 100644 web/src/pages/canvas/canvas-version-history.css create mode 100644 web/src/pages/canvas/canvas-version-history.tsx create mode 100644 web/src/pages/canvas/canvas-version-preview.tsx create mode 100644 web/src/services/canvas-sync-drafts.ts create mode 100644 web/test/canvas-remote-revision.test.ts diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 90819f921..18cfbfe7f 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -135,10 +135,14 @@ func primaryKeyColumn[T any](db *gorm.DB) (string, error) { if err := statement.Parse(new(T)); err != nil { return "", err } - if len(statement.Schema.PrimaryFields) != 1 { - return "", fmt.Errorf("表 %s 必须有且只有一个主键", statement.Schema.Table) + if len(statement.Schema.PrimaryFields) == 0 { + return "", fmt.Errorf("表 %s 必须有主键", statement.Schema.Table) } - return statement.Schema.PrimaryFields[0].DBName, nil + columns := make([]string, 0, len(statement.Schema.PrimaryFields)) + for _, field := range statement.Schema.PrimaryFields { + columns = append(columns, field.DBName) + } + return strings.Join(columns, ", "), nil } var timeType = reflect.TypeOf(time.Time{}) @@ -279,6 +283,8 @@ func migrations() []tableMigration { migrateTable[model.WorkflowStepTask]("workflow_step_tasks"), migrateTable[model.ProductionTaskLink]("production_task_links"), migrateTable[model.CanvasProject]("canvas_projects"), + migrateTable[model.CanvasSnapshot]("canvas_snapshots"), + migrateTable[model.CanvasSnapshotResource]("canvas_snapshot_resources"), migrateTable[model.CanvasShare]("canvas_shares"), migrateTable[model.PromptTemplate]("prompt_templates"), migrateTable[model.UserPromptCustomization]("user_prompt_customizations"), diff --git a/backend/cmd/migrate-sqlite-postgres/main_test.go b/backend/cmd/migrate-sqlite-postgres/main_test.go index 9399c4b21..c7724b46f 100644 --- a/backend/cmd/migrate-sqlite-postgres/main_test.go +++ b/backend/cmd/migrate-sqlite-postgres/main_test.go @@ -5,7 +5,9 @@ import ( "testing" "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "gorm.io/gorm" "gorm.io/gorm/schema" ) @@ -34,3 +36,30 @@ func TestMigrationListCoversSchemaModels(t *testing.T) { } } } + +func TestCanvasHistoryCompositeKeyMigration(t *testing.T) { + source, err := database.Open(database.Config{Driver: "sqlite", DSN: "file:history-migration-source?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + target, err := database.Open(database.Config{Driver: "sqlite", DSN: "file:history-migration-target?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + for _, db := range []*gorm.DB{source, target} { + if err := db.AutoMigrate(&model.Resource{}, &model.CanvasSnapshotResource{}); err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Resource{ID: "resource"}).Error; err != nil { + t.Fatal(err) + } + } + refs := []model.CanvasSnapshotResource{{SnapshotID: "b", ResourceID: "resource"}, {SnapshotID: "a", ResourceID: "resource"}} + if err := source.Create(&refs).Error; err != nil { + t.Fatal(err) + } + count, err := migrateTable[model.CanvasSnapshotResource]("canvas_snapshot_resources").run(source, target, true) + if err != nil || count != 2 { + t.Fatalf("history refs not copied/verified: %d %v", count, err) + } +} diff --git a/backend/internal/app/admin_storage_delete.go b/backend/internal/app/admin_storage_delete.go index 39bf37656..39ae100b4 100644 --- a/backend/internal/app/admin_storage_delete.go +++ b/backend/internal/app/admin_storage_delete.go @@ -159,7 +159,7 @@ func (s *Service) DeleteAdminResources(actor *model.User, req AdminResourceDelet audits = append(audits, *event) } if err := s.repo.DeleteAdminResources(deletable, deletionJobs, audits); err != nil { - if errors.Is(err, repository.ErrAdminResourceDeleteChanged) || errors.Is(err, repository.ErrAdminResourceStillReferenced) { + if errors.Is(err, repository.ErrAdminResourceDeleteChanged) || errors.Is(err, repository.ErrAdminResourceStillReferenced) || errors.Is(err, repository.ErrCanvasHistoryResourceReferenced) { return nil, BadAuthRequest("资源状态或引用已变化,请刷新后重试") } return nil, err diff --git a/backend/internal/app/canvas_bridge.go b/backend/internal/app/canvas_bridge.go index 17a0abf7f..f993af199 100644 --- a/backend/internal/app/canvas_bridge.go +++ b/backend/internal/app/canvas_bridge.go @@ -2,23 +2,26 @@ package app import ( "encoding/json" + "errors" "io" + "time" "infinite-canvas/backend/internal/assets" "infinite-canvas/backend/internal/canvas" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" ) type ( - CanvasShareRequest = canvas.CanvasShareRequest - CanvasShareStatus = canvas.CanvasShareStatus - PublicCanvasShare = canvas.PublicCanvasShare - AssetsSyncRequest = canvas.AssetsSyncRequest - CanvasProjectsSyncRequest = canvas.CanvasProjectsSyncRequest - UserDataSummary = canvas.UserDataSummary - UserDataSnapshot = canvas.UserDataSnapshot - CanvasLibrarySummary = canvas.CanvasLibrarySummary - CanvasLibraryPage = canvas.CanvasLibraryPage + CanvasShareRequest = canvas.CanvasShareRequest + CanvasShareStatus = canvas.CanvasShareStatus + PublicCanvasShare = canvas.PublicCanvasShare + AssetsSyncRequest = canvas.AssetsSyncRequest + CanvasHistoryList = canvas.CanvasHistoryList + UserDataSummary = canvas.UserDataSummary + UserDataSnapshot = canvas.UserDataSnapshot + CanvasLibrarySummary = canvas.CanvasLibrarySummary + CanvasLibraryPage = canvas.CanvasLibraryPage ) type canvasHost struct { @@ -207,8 +210,32 @@ func (s *Service) DeleteUserCanvasProject(userID string, id string) error { return s.canvasDomain().DeleteUserCanvasProject(userID, id) } -func (s *Service) ReplaceUserCanvasProjects(userID string, req CanvasProjectsSyncRequest) ([]json.RawMessage, error) { - return s.canvasDomain().ReplaceUserCanvasProjects(userID, req) +func (s *Service) CanvasHistory(userID, canvasID string) (CanvasHistoryList, error) { + return s.canvasDomain().CanvasHistory(userID, canvasID) +} + +func (s *Service) CanvasHistorySnapshot(userID, canvasID, snapshotID string) (*model.CanvasSnapshot, error) { + return s.canvasDomain().CanvasHistorySnapshot(userID, canvasID, snapshotID) +} + +func (s *Service) RestoreCanvasHistory(userID, canvasID, snapshotID string, revision *int64) (UserDataSummary, error) { + return s.canvasDomain().RestoreCanvasHistory(userID, canvasID, snapshotID, revision) +} + +func saveCreationCanvasWithHistory(repo *repository.Repository, project *model.CanvasProject, previous string) error { + before, err := repo.CanvasProjectForUser(project.UserID, project.ID) + if err != nil { + return err + } + if before.PayloadJSON != previous || before.Revision != project.Revision { + return repository.ErrCreationConflict + } + project.UpdatedAt = time.Now().UTC() + err = canvas.SaveDocumentWithHistory(repo, before, project, "automatic") + if errors.Is(err, repository.ErrCanvasRevisionConflict) { + return repository.ErrCreationConflict + } + return err } func (s *Service) UserAssetsByIDs(userID string, ids []string) ([]json.RawMessage, error) { diff --git a/backend/internal/app/canvas_history_resource_test.go b/backend/internal/app/canvas_history_resource_test.go new file mode 100644 index 000000000..e050c508f --- /dev/null +++ b/backend/internal/app/canvas_history_resource_test.go @@ -0,0 +1,68 @@ +package app + +import ( + "errors" + "infinite-canvas/backend/internal/model" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestCanvasHistoryProtectsMediaAndDeletionWorker(t *testing.T) { + svc, db, dataDir := newResourceDeletionTestService(t) + key := "users/user-1/video/history.mp4" + file := filepath.Join(dataDir, "resources", filepath.FromSlash(key)) + if err := os.MkdirAll(filepath.Dir(file), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("video"), 0o640); err != nil { + t.Fatal(err) + } + resource := model.Resource{ID: "historical-video", UserID: "user-1", Provider: "local", ObjectKey: key, Status: model.ResourceStatusReady} + asset := model.Asset{ID: "history-asset", UserID: "user-1", Title: "video", PayloadJSON: `{"data":{"storageKey":"resource:historical-video"}}`} + snapshot := model.CanvasSnapshot{ID: "history-snapshot", CanvasID: "canvas", UserID: "user-1", Revision: 1, Title: "wedding", PayloadJSON: `{}`, CreatedAt: time.Now()} + ref := model.CanvasSnapshotResource{SnapshotID: snapshot.ID, ResourceID: resource.ID} + for _, item := range []any{&resource, &asset, &snapshot, &ref} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + if err := svc.DeleteUserAsset("user-1", asset.ID); err == nil || !strings.Contains(err.Error(), "画布历史版本") { + t.Fatalf("history reference ignored: %v", err) + } + // A previously queued physical deletion must also honor a later history reference. + job := resourceDeletionJobs("user-1", map[string]*model.Resource{key: &resource})[0] + job.ResourceID = "deleted-resource-alias" + if err := db.Create(&job).Error; err != nil { + t.Fatal(err) + } + svc.drainResourceDeletionJobs(1) + if _, err := os.Stat(file); err != nil { + t.Fatalf("history media removed: %v", err) + } + var pending model.ResourceDeletionJob + if err := db.First(&pending, "id = ?", job.ID).Error; err != nil { + t.Fatal(err) + } + if pending.Attempts < 1 || pending.LastError == "" { + t.Fatalf("protected deletion was not deferred: %#v", pending) + } + if err := db.Delete(&ref).Error; err != nil { + t.Fatal(err) + } + if err := db.Delete(&asset).Error; err != nil { + t.Fatal(err) + } + if err := db.Delete(&resource).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&pending).Update("next_attempt_at", time.Now().Add(-time.Minute)).Error; err != nil { + t.Fatal(err) + } + svc.drainResourceDeletionJobs(1) + if _, err := os.Stat(file); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unreferenced media not cleaned: %v", err) + } +} diff --git a/backend/internal/app/cloud_agent_canvas_events.go b/backend/internal/app/cloud_agent_canvas_events.go index a9d2193e4..1458c4ab3 100644 --- a/backend/internal/app/cloud_agent_canvas_events.go +++ b/backend/internal/app/cloud_agent_canvas_events.go @@ -47,7 +47,9 @@ func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state } nodes := cloudAgentObjectChanges(before["nodes"], after["nodes"]) edges := cloudAgentObjectChanges(before["connections"], after["connections"]) - if len(nodes) == 0 && len(edges) == 0 { + if !cloudAgentPatchCoversDocument(before, after) { + // A partial delta must never acknowledge a revision containing omitted edits. + state.event(runID, "canvas_updated", map[string]any{"canvasId": input.CanvasID, "operation": input.Operation, "requiresRefresh": true}) return nil } actions := []map[string]any{} @@ -126,7 +128,7 @@ func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state } payload := map[string]any{ "canvasId": input.CanvasID, "operation": input.Operation, "actions": actions, - "canvasPatch": map[string]any{"canvasId": input.CanvasID, "updatedAt": after["updatedAt"], "nodes": nodes, "connections": edges}, + "canvasPatch": map[string]any{"canvasId": input.CanvasID, "baseRevision": canvas.Revision - 1, "revision": canvas.Revision, "updatedAt": after["updatedAt"], "nodes": nodes, "connections": edges}, } if input.Preview != nil { payload["preview"] = input.Preview @@ -138,3 +140,28 @@ func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state state.event(runID, "canvas_updated", payload) return nil } + +func cloudAgentPatchCoversDocument(before, after map[string]any) bool { + for _, key := range []string{"nodes", "connections"} { + old, next := creationMaps(before[key]), creationMaps(after[key]) + if len(next) < len(old) { + return false + } + // The delta merger updates existing items in place and appends new items. + for i, item := range old { + if item["id"] != next[i]["id"] { + return false + } + } + } + content := func(doc map[string]any) map[string]any { + result := map[string]any{} + for key, value := range doc { + if key != "nodes" && key != "connections" && key != "updatedAt" && key != "viewport" { + result[key] = value + } + } + return result + } + return reflect.DeepEqual(content(before), content(after)) +} diff --git a/backend/internal/app/cloud_agent_canvas_events_test.go b/backend/internal/app/cloud_agent_canvas_events_test.go index 2df493a3a..00e795266 100644 --- a/backend/internal/app/cloud_agent_canvas_events_test.go +++ b/backend/internal/app/cloud_agent_canvas_events_test.go @@ -84,6 +84,22 @@ func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *te t.Fatalf("submission failed to lock the existing draft: %+v", submission) } completion := agentCanvasPatchForOperation(t, state, "generate_media_complete")["canvasPatch"].(map[string]any) + previousRevision := patch["baseRevision"] + for _, delta := range []map[string]any{patch, submission, completion} { + if delta["baseRevision"] != previousRevision { + t.Fatalf("discontinuous Agent revision: %+v", delta) + } + base, ok := delta["baseRevision"].(float64) + next, nextOK := delta["revision"].(float64) + if !ok || !nextOK || next != base+1 { + t.Fatalf("invalid Agent revisions: %+v", delta) + } + previousRevision = delta["revision"] + } + history, err := s.CanvasHistory("user", "agent-canvas") + if err != nil || len(history.Snapshots) == 0 || float64(history.CurrentRevision) != previousRevision { + t.Fatalf("Agent save did not retain history/revision: %+v %v", history, err) + } changes = creationMaps(completion["nodes"]) if len(changes) != 1 || len(creationMaps(completion["connections"])) != 0 { t.Fatalf("completion includes unrelated canvas data: %+v", completion) @@ -127,6 +143,34 @@ func TestCloudAgentMediaImageSourceHasActionableCorrection(t *testing.T) { } } +func TestCloudAgentDeletionInvalidatesTheWholeCanvasRevision(t *testing.T) { + s, _, _ := agentMediaFixture(t) + canvas, err := s.repo.CanvasProjectForUser("user", "agent-canvas") + if err != nil { + t.Fatal(err) + } + previous := canvas.PayloadJSON + doc, err := creationDocument(previous) + if err != nil { + t.Fatal(err) + } + nodes := creationMaps(doc["nodes"]) + doc["nodes"] = nodes[1:] + nodes[1]["title"] = "Also updated" + raw, _ := json.Marshal(doc) + canvas.PayloadJSON = string(raw) + if err := saveCreationCanvasWithHistory(s.repo, canvas, previous); err != nil { + t.Fatal(err) + } + state := cloudAgentRuntime{} + if err := emitCloudAgentCanvasChange(s.repo, "run", &state, cloudAgentMutationInput{UserID: "user", CanvasID: canvas.ID, BeforeJSON: previous}); err != nil { + t.Fatal(err) + } + if len(state.Events) != 1 || state.Events[0].Payload["requiresRefresh"] != true || state.Events[0].Payload["canvasPatch"] != nil { + t.Fatalf("incomplete delta would acknowledge a deleted node: %+v", state.Events) + } +} + func TestCloudAgentCanvasOperationTraceSharesToolCallID(t *testing.T) { s, _, args := agentMediaFixture(t) run, state := agentMediaRun(t, s, args, "auto") diff --git a/backend/internal/app/cloud_agent_media.go b/backend/internal/app/cloud_agent_media.go index f91508aef..e62078e74 100644 --- a/backend/internal/app/cloud_agent_media.go +++ b/backend/internal/app/cloud_agent_media.go @@ -520,7 +520,7 @@ func saveCloudAgentDocument(repo *repository.Repository, canvas *model.CanvasPro } before := canvas.PayloadJSON canvas.PayloadJSON = string(raw) - return repo.CompareSaveCreationCanvas(canvas, before) + return saveCreationCanvasWithHistory(repo, canvas, before) } // Called inside the same transaction as the task, charge reservation and Agent checkpoint. diff --git a/backend/internal/app/cloud_agent_storyboard_test.go b/backend/internal/app/cloud_agent_storyboard_test.go index e23902408..470043677 100644 --- a/backend/internal/app/cloud_agent_storyboard_test.go +++ b/backend/internal/app/cloud_agent_storyboard_test.go @@ -296,7 +296,7 @@ func TestCloudAgentStoryboardRejectsUnsafeOrStaleMutations(t *testing.T) { stored, _ := s.repo.CanvasProjectForUser("user", canvas.ID) before := stored.PayloadJSON stored.PayloadJSON = string(raw) - if err := s.repo.CompareSaveCreationCanvas(stored, before); err != nil { + if err := saveCreationCanvasWithHistory(s.repo, stored, before); err != nil { t.Fatal(err) } wrongType := cloudAgentStoryboardCall(t, "canvas_edit_storyboard", "wrong-type", map[string]any{ diff --git a/backend/internal/app/cloud_agent_undo.go b/backend/internal/app/cloud_agent_undo.go index 150a8a2c2..1544bb9e4 100644 --- a/backend/internal/app/cloud_agent_undo.go +++ b/backend/internal/app/cloud_agent_undo.go @@ -100,7 +100,7 @@ func (s *Service) UndoCloudAgentCanvas(userID, runID, stepID, expectedSnapshotHa } previous := canvas.PayloadJSON canvas.PayloadJSON = mutation.BeforeJSON - if err := repo.CompareSaveCreationCanvas(canvas, previous); err != nil { + if err := saveCreationCanvasWithHistory(repo, canvas, previous); err != nil { if errors.Is(err, repository.ErrCreationConflict) { return creationConflict("画布已发生后续变化,未执行撤销") } diff --git a/backend/internal/app/cloud_agent_undo_test.go b/backend/internal/app/cloud_agent_undo_test.go index bd3e187ae..4d08cdbb7 100644 --- a/backend/internal/app/cloud_agent_undo_test.go +++ b/backend/internal/app/cloud_agent_undo_test.go @@ -111,13 +111,13 @@ func TestUndoCloudAgentCanvasRejectsChangedCanvasAndSubmittedTask(t *testing.T) afterHash := cloudAgentCanvasHash(changedDoc) // A user edit after the Agent mutation must make the compare-and-restore fail. changed.PayloadJSON = `{"nodes":[{"id":"user-node","type":"text","title":"用户编辑","metadata":{"content":"后来修改"}}]}` - if err := s.repo.CompareSaveCreationCanvas(changed, changed.PayloadJSON); err == nil { + if err := saveCreationCanvasWithHistory(s.repo, changed, changed.PayloadJSON); err == nil { t.Fatal("test setup unexpectedly saved with the new value as previous snapshot") } latest, _ := s.repo.CanvasProjectForUser("user", "agent-canvas") previous := latest.PayloadJSON latest.PayloadJSON = `{"nodes":[{"id":"user-node","type":"text","title":"用户编辑","metadata":{"content":"后来修改"}}]}` - if err := s.repo.CompareSaveCreationCanvas(latest, previous); err != nil { + if err := saveCreationCanvasWithHistory(s.repo, latest, previous); err != nil { t.Fatal(err) } _, err = s.UndoCloudAgentCanvas("user", run.ID, call.ID, afterHash, "冲突") diff --git a/backend/internal/app/creation_canvas.go b/backend/internal/app/creation_canvas.go index 583023053..735a548b8 100644 --- a/backend/internal/app/creation_canvas.go +++ b/backend/internal/app/creation_canvas.go @@ -246,7 +246,7 @@ func (s *Service) CommitCreationCanvas(userID, id string, req CreationRequest) ( if err = validateStructuredStorageQuotaWithPolicy(usage, "canvas", false, int64(len(req.Document)-len(previous)), policy.Resource); err != nil { return err } - if err = repo.CompareSaveCreationCanvas(canvas, previous); err != nil { + if err = saveCreationCanvasWithHistory(repo, canvas, previous); err != nil { return err } out = map[string]any{"snapshotHash": creationHash(doc)} diff --git a/backend/internal/app/project.go b/backend/internal/app/project.go index 360ea1a75..c2494b02b 100644 --- a/backend/internal/app/project.go +++ b/backend/internal/app/project.go @@ -609,7 +609,7 @@ func (s *Service) UnlinkCanvasProject(userID string, projectID string, canvasID return err } // 关系列、同步快照和更新时间必须原子更新,否则浏览器会用旧 projectId 把关系重新写回。 - return s.repo.UnassignCanvasFromProject(userID, projectID, canvas.ID, payloadJSON, now) + return s.repo.UnassignCanvasFromProject(userID, projectID, canvas.ID, payloadJSON, now, canvas.Revision) } func canvasPayloadWithoutProject(payloadJSON string, updatedAt time.Time) (string, error) { diff --git a/backend/internal/app/project_delete_test.go b/backend/internal/app/project_delete_test.go index 4420a77d4..9b620c154 100644 --- a/backend/internal/app/project_delete_test.go +++ b/backend/internal/app/project_delete_test.go @@ -22,6 +22,8 @@ func newProjectDeleteTestService(t *testing.T) (*Service, *gorm.DB) { &model.Project{}, &model.ProjectUnit{}, &model.CanvasProject{}, + &model.CanvasSnapshot{}, + &model.CanvasSnapshotResource{}, &model.CanvasShare{}, &model.CanvasUnitLink{}, &model.ProjectAssetLink{}, diff --git a/backend/internal/app/project_settings_test.go b/backend/internal/app/project_settings_test.go index e23f821cf..75d94b89e 100644 --- a/backend/internal/app/project_settings_test.go +++ b/backend/internal/app/project_settings_test.go @@ -20,6 +20,7 @@ func newProjectSettingsTestService(t *testing.T) (*Service, *gorm.DB) { if err := db.AutoMigrate( &model.Project{}, &model.ProjectUnit{}, &model.Resource{}, &model.Asset{}, &model.AssetVersion{}, &model.AssetRepresentation{}, &model.CanvasProject{}, &model.StyleProfile{}, &model.ProjectAssetCandidate{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}, + &model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}, &model.Shot{}, &model.ShotArtifact{}, &model.VoiceProfile{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.CreationRun{}, &model.CreationSubmission{}, diff --git a/backend/internal/app/resource_delete.go b/backend/internal/app/resource_delete.go index a627ee2f7..bac55654c 100644 --- a/backend/internal/app/resource_delete.go +++ b/backend/internal/app/resource_delete.go @@ -14,6 +14,7 @@ import ( "infinite-canvas/backend/internal/assets" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" qiniuAuth "github.com/qiniu/go-sdk/v7/auth" qiniuStorage "github.com/qiniu/go-sdk/v7/storage" @@ -139,6 +140,9 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er // 业务记录和 Outbox 必须在同一事务提交。事务失败时物理文件完全不动; // 提交成功后由幂等 worker 清理,进程退出或对象存储暂时失败都可继续重试。 if err := s.repo.DeleteAssetAndResources(userID, assetID, ownedIDs, deletionJobs); err != nil { + if errors.Is(err, repository.ErrCanvasHistoryResourceReferenced) { + return BadAuthRequest("素材仍被画布历史版本引用,已保留文件") + } return fmt.Errorf("素材记录删除失败,请重试:%w", err) } if len(deletionJobs) > 0 { @@ -234,6 +238,13 @@ func (s *Service) deleteStoredResourceObject(userID string, resource *model.Reso if strings.TrimSpace(resource.ObjectKey) == "" { return fmt.Errorf("资源 %s 的存储路径为空", resource.ID) } + protected, err := s.repo.CanvasHistoryReferencesObject(resource) + if err != nil { + return err + } + if protected { + return errors.New("资源仍被画布历史版本引用") + } switch strings.ToLower(strings.TrimSpace(resource.Provider)) { case "", "local": return s.deleteLocalResourceObject(resource.ObjectKey) diff --git a/backend/internal/assets/references.go b/backend/internal/assets/references.go index 828279cd3..96ff015bf 100644 --- a/backend/internal/assets/references.go +++ b/backend/internal/assets/references.go @@ -100,7 +100,7 @@ func isBareResourceIDField(field string) bool { func isResourceLocatorField(field string) bool { switch field { - case "storageKey", "content", "url", "dataUrl", "coverUrl", "imageUrl", "videoUrl", "audioUrl", "referenceUrl", "referenceUrls", "artifactRef", "providerArtifactRef": + case "storageKey", "content", "previewContent", "drawingPreviewStorageKey", "drawingPreviewUrl", "url", "dataUrl", "coverUrl", "imageUrl", "videoUrl", "audioUrl", "referenceUrl", "referenceUrls", "artifactRef", "providerArtifactRef": return true default: return false diff --git a/backend/internal/canvas/canvas_history.go b/backend/internal/canvas/canvas_history.go new file mode 100644 index 000000000..eba418795 --- /dev/null +++ b/backend/internal/canvas/canvas_history.go @@ -0,0 +1,185 @@ +package canvas + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "infinite-canvas/backend/internal/assets" + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/gorm" +) + +const canvasHistoryLimit = 20 +const canvasHistoryInterval = 5 * time.Minute + +// 浏览器、云端 Agent 和创作任务共用此入口;外层事务仍负责任务和画布的一致性。 +func SaveDocumentWithHistory(repo *repository.Repository, before *model.CanvasProject, after *model.CanvasProject, reason string) error { + snapshot, resourceIDs, err := buildCanvasSnapshot(before, *after, reason) + if err != nil { + return err + } + var restoredIDs []string + if reason == "before_restore" { + refs := map[string]struct{}{} + if err := assets.CollectOwnedDocumentReferences(after.PayloadJSON, refs); err != nil { + return err + } + restoredIDs = assets.SortedIDs(refs) + } + return repo.SaveCanvasWithSnapshot(after, snapshot, resourceIDs, restoredIDs, after.UpdatedAt.Add(-canvasHistoryInterval), canvasHistoryLimit, reason == "before_restore") +} + +func canvasRevisionConflict() error { + return kernel.NewAppError(http.StatusConflict, "云端画布已有更新,已停止覆盖;请保留本地草稿并加载最新版本") +} + +type CanvasHistoryList struct { + Snapshots []model.CanvasSnapshot `json:"snapshots"` + CurrentRevision int64 `json:"currentRevision"` +} + +func (s *Service) scopedCanvasHistoryProject(userID string, canvasID string) (*model.CanvasProject, error) { + project, err := s.repo.CanvasProjectMetadata(userID, strings.TrimSpace(canvasID)) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, kernel.NewAppError(http.StatusNotFound, "画布不存在或无权访问") + } + if err != nil { + return nil, err + } + return project, nil +} + +func (s *Service) CanvasHistory(userID string, canvasID string) (CanvasHistoryList, error) { + project, err := s.scopedCanvasHistoryProject(userID, canvasID) + if err != nil { + return CanvasHistoryList{}, err + } + items, err := s.repo.CanvasSnapshots(project.UserID, project.ID, canvasHistoryLimit) + return CanvasHistoryList{Snapshots: items, CurrentRevision: project.Revision}, err +} + +func (s *Service) CanvasHistorySnapshot(userID string, canvasID, snapshotID string) (*model.CanvasSnapshot, error) { + project, err := s.scopedCanvasHistoryProject(userID, canvasID) + if err != nil { + return nil, err + } + item, err := s.repo.CanvasSnapshot(project.UserID, project.ID, snapshotID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, kernel.NewAppError(http.StatusNotFound, "历史版本不存在或已过期,请刷新历史列表") + } + return item, err +} + +func (s *Service) RestoreCanvasHistory(userID string, canvasID, snapshotID string, revision *int64) (UserDataSummary, error) { + if revision == nil { + return UserDataSummary{}, kernel.NewAppError(http.StatusPreconditionRequired, "请先刷新画布版本再恢复") + } + current, err := s.scopedCanvasHistoryProject(userID, canvasID) + if err != nil { + return UserDataSummary{}, err + } + if current.Revision != *revision { + return UserDataSummary{}, canvasRevisionConflict() + } + item, err := s.CanvasHistorySnapshot(userID, canvasID, snapshotID) + if err != nil { + return UserDataSummary{}, err + } + var payload map[string]json.RawMessage + if err := json.Unmarshal([]byte(item.PayloadJSON), &payload); err != nil { + return UserDataSummary{}, err + } + // Restore document content while retaining its present ownership and business association. + for key, value := range map[string]any{ + "id": current.ID, "revision": *revision, "createdAt": current.CreatedAt, + "projectId": current.ProjectID, + } { + payload[key], _ = json.Marshal(value) + } + raw, err := json.Marshal(payload) + if err != nil { + return UserDataSummary{}, err + } + return s.upsertUserCanvasProjectWithHistory(userID, raw, "before_restore") +} + +func canvasHistoryContent(project model.CanvasProject) ([]byte, error) { + raw, err := canvasProjectPayload(project) + if err != nil { + return nil, err + } + var payload map[string]any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil { + return nil, err + } + for _, key := range []string{"revision", "updatedAt", "createdAt", "viewport", "remoteContentHash"} { + delete(payload, key) + } + return json.Marshal(payload) +} + +func buildCanvasSnapshot(before *model.CanvasProject, after model.CanvasProject, reason string) (*model.CanvasSnapshot, []string, error) { + if before == nil { + return nil, nil, nil + } + oldContent, err := canvasHistoryContent(*before) + if err != nil { + return nil, nil, err + } + newContent, err := canvasHistoryContent(after) + if err != nil { + return nil, nil, err + } + if reason != "before_restore" && bytes.Equal(oldContent, newContent) { + return nil, nil, nil + } + raw, err := canvasProjectPayload(*before) + if err != nil { + return nil, nil, err + } + var counts struct { + Nodes []json.RawMessage + Connections []json.RawMessage + } + if err := json.Unmarshal(raw, &counts); err != nil { + return nil, nil, err + } + resources := map[string]struct{}{} + if err := assets.CollectOwnedDocumentReferences(string(raw), resources); err != nil { + return nil, nil, err + } + return &model.CanvasSnapshot{ + ID: kernel.NewID(), CanvasID: before.ID, UserID: before.UserID, Revision: before.Revision, + Title: before.Title, NodeCount: len(counts.Nodes), ConnectionCount: len(counts.Connections), + PayloadJSON: string(raw), PayloadBytes: len(raw), Reason: reason, + ContentUpdatedAt: before.UpdatedAt, CreatedAt: after.UpdatedAt, + }, assets.SortedIDs(resources), nil +} + +// Row metadata is authoritative, including changes made by project association operations. +func canvasProjectPayload(project model.CanvasProject) (json.RawMessage, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal([]byte(project.PayloadJSON), &payload); err != nil { + return nil, err + } + if payload == nil { + return nil, kernel.BadAuthRequest("画布数据格式错误") + } + for key, value := range map[string]any{"id": project.ID, "title": project.Title, "projectId": project.ProjectID, "revision": project.Revision, "createdAt": project.CreatedAt, "updatedAt": project.UpdatedAt} { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + payload[key] = encoded + } + return json.Marshal(payload) +} diff --git a/backend/internal/canvas/canvas_history_test.go b/backend/internal/canvas/canvas_history_test.go new file mode 100644 index 000000000..7157a2a59 --- /dev/null +++ b/backend/internal/canvas/canvas_history_test.go @@ -0,0 +1,96 @@ +package canvas + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" +) + +func TestCanvasHistoryRestore(t *testing.T) { + svc := newCanvasHistoryTestService(t) + actor := &model.User{ID: "owner"} + save := func(raw string) UserDataSummary { + t.Helper() + result, err := svc.UpsertUserCanvasProject(actor.ID, json.RawMessage(raw)) + if err != nil { + t.Fatal(err) + } + return result + } + save(`{"id":"canvas","revision":0,"title":"original","nodes":[{"id":"video"}],"connections":[]}`) + save(`{"id":"canvas","revision":1,"title":"original","nodes":[{"id":"video"}],"connections":[],"viewport":{"x":20}}`) + list, err := svc.CanvasHistory(actor.ID, "canvas") + if err != nil || len(list.Snapshots) != 0 { + t.Fatalf("no-op created snapshot: %#v %v", list, err) + } + save(`{"id":"canvas","revision":2,"title":"edited","nodes":[],"connections":[]}`) + list, err = svc.CanvasHistory(actor.ID, "canvas") + if err != nil || len(list.Snapshots) != 1 || list.CurrentRevision != 3 || list.Snapshots[0].NodeCount != 1 { + t.Fatalf("history: %#v %v", list, err) + } + entry := list.Snapshots[0] + if entry.PayloadJSON != "" { + t.Fatal("history list loaded full payload") + } + preview, err := svc.CanvasHistorySnapshot(actor.ID, "canvas", entry.ID) + if err != nil || !strings.Contains(preview.PayloadJSON, "video") { + t.Fatalf("preview = %#v %v", preview, err) + } + for _, scenario := range []struct { + actor *model.User + canvasID string + }{ + {actor: &model.User{ID: "stranger"}, canvasID: "canvas"}, + {actor: actor, canvasID: "missing-canvas"}, + } { + var notFound *kernel.AppError + if _, err := svc.CanvasHistory(scenario.actor.ID, scenario.canvasID); !errors.As(err, ¬Found) || notFound.Status != http.StatusNotFound { + t.Fatalf("inaccessible history must return 404: %v", err) + } + if _, err := svc.CanvasHistorySnapshot(scenario.actor.ID, scenario.canvasID, entry.ID); !errors.As(err, ¬Found) || notFound.Status != http.StatusNotFound { + t.Fatalf("inaccessible preview must return 404: %v", err) + } + currentRevision := int64(3) + if _, err := svc.RestoreCanvasHistory(scenario.actor.ID, scenario.canvasID, entry.ID, ¤tRevision); !errors.As(err, ¬Found) || notFound.Status != http.StatusNotFound { + t.Fatalf("inaccessible restore must return 404: %v", err) + } + } + if _, err := svc.CanvasHistorySnapshot(actor.ID, "canvas", "other-canvas-snapshot"); err == nil { + t.Fatal("unrelated snapshot readable") + } + if _, err := svc.RestoreCanvasHistory(actor.ID, "canvas", entry.ID, nil); err == nil { + t.Fatal("missing revision accepted") + } + stale := int64(2) + _, err = svc.RestoreCanvasHistory(actor.ID, "canvas", entry.ID, &stale) + var appError *kernel.AppError + if !errors.As(err, &appError) || appError.Status != http.StatusConflict { + t.Fatalf("stale restore = %v", err) + } + current := int64(3) + result, err := svc.RestoreCanvasHistory(actor.ID, "canvas", entry.ID, ¤t) + if err != nil || result.Revision != 4 { + t.Fatalf("restore = %#v %v", result, err) + } + raw, _ := svc.UserCanvasProject(actor.ID, "canvas") + if !strings.Contains(string(raw), "video") || !strings.Contains(string(raw), "original") { + t.Fatalf("wrong restored content: %s", raw) + } + list, _ = svc.CanvasHistory(actor.ID, "canvas") + if len(list.Snapshots) != 2 || list.Snapshots[0].Revision != 3 || list.Snapshots[0].Reason != "before_restore" { + t.Fatalf("pre-restore backup missing: %#v", list) + } + current = 4 + if _, err := svc.RestoreCanvasHistory(actor.ID, "canvas", list.Snapshots[0].ID, ¤t); err != nil { + t.Fatal(err) + } + raw, _ = svc.UserCanvasProject(actor.ID, "canvas") + if strings.Contains(string(raw), "video") { + t.Fatal("could not undo restore") + } +} diff --git a/backend/internal/canvas/canvas_revision_test.go b/backend/internal/canvas/canvas_revision_test.go new file mode 100644 index 000000000..3e8f97d22 --- /dev/null +++ b/backend/internal/canvas/canvas_revision_test.go @@ -0,0 +1,77 @@ +package canvas + +import ( + "encoding/json" + "errors" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "infinite-canvas/backend/internal/repository" + "net/http" + "path/filepath" + "testing" + + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" +) + +func TestCanvasSaveRevisionContract(t *testing.T) { + svc := newCanvasHistoryTestService(t) + actor := &model.User{ID: "owner"} + missing := json.RawMessage(`{"id":"canvas","nodes":[]}`) + _, err := svc.UpsertUserCanvasProject(actor.ID, missing) + var appError *kernel.AppError + if !errors.As(err, &appError) || appError.Status != http.StatusPreconditionRequired { + t.Fatalf("unversioned save = %v", err) + } + raw := json.RawMessage(`{"id":"canvas","revision":0,"title":"initial","nodes":[{"id":"old"}],"viewport":{"x":999,"y":0,"k":2}}`) + created, err := svc.UpsertUserCanvasProject(actor.ID, raw) + if err != nil { + t.Fatal(err) + } + if created.Revision != 1 { + t.Fatalf("revision = %d", created.Revision) + } + update := json.RawMessage(`{"id":"canvas","revision":1,"title":"new","nodes":[{"id":"old"},{"id":"video"}],"updatedAt":"2099-01-01T00:00:00Z","remoteContentHash":"local-only","viewport":{"x":500,"y":10,"k":3}}`) + saved, err := svc.UpsertUserCanvasProject(actor.ID, update) + if err != nil { + t.Fatal(err) + } + if saved.Revision != 2 || saved.UpdatedAt.Year() == 2099 { + t.Fatalf("invalid server metadata: %#v", saved) + } + _, err = svc.UpsertUserCanvasProject(actor.ID, update) + if !errors.As(err, &appError) || appError.Status != http.StatusConflict { + t.Fatalf("stale save = %v", err) + } + got, err := svc.UserCanvasProject(actor.ID, "canvas") + if err != nil { + t.Fatal(err) + } + var project map[string]any + if err := json.Unmarshal(got, &project); err != nil { + t.Fatal(err) + } + if project["revision"] != float64(2) || len(project["nodes"].([]any)) != 2 { + t.Fatalf("read lost metadata/content: %s", got) + } + if project["remoteContentHash"] != nil || project["viewport"].(map[string]any)["x"] != float64(0) { + t.Fatalf("content save persisted local preferences: %s", got) + } + if _, err := svc.UserCanvasProject("unrelated", "canvas"); err == nil { + t.Fatal("unrelated user could read canvas") + } +} + +func newCanvasHistoryTestService(t *testing.T) *Service { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "canvas.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.CanvasProject{}, &model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}, &model.Resource{}, &model.Asset{}); err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + return New(repository.New(db), nil) +} diff --git a/backend/internal/canvas/user_data.go b/backend/internal/canvas/user_data.go index e0e06fa87..151876d6e 100644 --- a/backend/internal/canvas/user_data.go +++ b/backend/internal/canvas/user_data.go @@ -4,11 +4,13 @@ import ( "encoding/json" "errors" "infinite-canvas/backend/internal/kernel" + "net/http" "strings" "time" "unicode/utf8" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" "gorm.io/gorm" ) @@ -17,19 +19,30 @@ type AssetsSyncRequest struct { Assets []json.RawMessage `json:"assets"` } -type CanvasProjectsSyncRequest struct { - Projects []json.RawMessage `json:"projects"` +type UserDataSummary struct { + ID string `json:"id"` + FolderID string `json:"folderId,omitempty"` + Kind string `json:"kind,omitempty"` + Category string `json:"category,omitempty"` + Status string `json:"status,omitempty"` + Title string `json:"title"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Revision int64 `json:"revision,omitempty"` + SaveAudit *CanvasSaveAudit `json:"-"` } -type UserDataSummary struct { - ID string `json:"id"` - FolderID string `json:"folderId,omitempty"` - Kind string `json:"kind,omitempty"` - Category string `json:"category,omitempty"` - Status string `json:"status,omitempty"` - Title string `json:"title"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` +type CanvasSaveAudit struct { + NodesBefore int + NodesAfter int +} + +func canvasNodeCount(payload string) int { + var document struct { + Nodes []json.RawMessage `json:"nodes"` + } + _ = json.Unmarshal([]byte(payload), &document) + return len(document.Nodes) } type UserDataSnapshot struct { @@ -173,7 +186,11 @@ func (s *Service) UserCanvasProjects(userID string) ([]json.RawMessage, error) { result := make([]json.RawMessage, 0, len(projects)) for _, project := range projects { if strings.TrimSpace(project.PayloadJSON) != "" { - result = append(result, json.RawMessage(project.PayloadJSON)) + payload, err := canvasProjectPayload(project) + if err != nil { + return nil, err + } + result = append(result, payload) } } return result, nil @@ -186,7 +203,7 @@ func (s *Service) UserCanvasProjectSummaries(userID string) ([]UserDataSummary, } result := make([]UserDataSummary, 0, len(projects)) for _, project := range projects { - result = append(result, UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt}) + result = append(result, UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, Revision: project.Revision}) } return result, nil } @@ -196,14 +213,31 @@ func (s *Service) UserCanvasProject(userID string, id string) (json.RawMessage, if err != nil { return nil, err } - return json.RawMessage(project.PayloadJSON), nil + return canvasProjectPayload(*project) } func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (UserDataSummary, error) { + return s.upsertUserCanvasProjectWithHistory(userID, raw, "automatic") +} + +func (s *Service) upsertUserCanvasProjectWithHistory(userID string, raw json.RawMessage, reason string) (UserDataSummary, error) { + var version struct { + Revision *int64 `json:"revision"` + } + if err := json.Unmarshal(raw, &version); err != nil { + return UserDataSummary{}, kernel.BadAuthRequest("画布版本格式错误") + } + if version.Revision == nil { + return UserDataSummary{}, kernel.NewAppError(http.StatusPreconditionRequired, "缺少画布版本,请保留本地草稿后重新加载画布") + } + if *version.Revision < 0 || *version.Revision >= 9007199254740991 { + return UserDataSummary{}, kernel.BadAuthRequest("画布版本无效") + } project, err := canvasProjectFromJSON(userID, raw) if err != nil { return UserDataSummary{}, err } + var audit CanvasSaveAudit err = s.host.WithStorageLock(func() error { if err := s.ValidateCanvasMediaAssets(userID, raw); err != nil { return err @@ -213,15 +247,51 @@ func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (U return existingErr } existingBytes := int64(0) + project.Revision = *version.Revision + project.UpdatedAt = time.Now().UTC() if existing != nil { existingBytes = int64(len([]byte(existing.PayloadJSON))) + project.CreatedAt = existing.CreatedAt } - if err := s.host.StructuredQuota(userID, "canvas", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(raw))-existingBytes); err != nil { + if (existing == nil && project.Revision != 0) || (existing != nil && project.Revision != existing.Revision) { + return canvasRevisionConflict() + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(raw, &payload); err != nil { return err } - if err := s.repo.UpsertCanvasProject(&project); err != nil { + delete(payload, "revision") + delete(payload, "remoteContentHash") + payload["viewport"] = json.RawMessage(`{"x":0,"y":0,"k":1}`) + if existing != nil { + var previous map[string]json.RawMessage + if json.Unmarshal([]byte(existing.PayloadJSON), &previous) == nil && previous["viewport"] != nil { + payload["viewport"] = previous["viewport"] + } + } + payload["createdAt"], _ = json.Marshal(project.CreatedAt) + payload["updatedAt"], _ = json.Marshal(project.UpdatedAt) + cleaned, err := json.Marshal(payload) + if err != nil { return err } + project.PayloadJSON = string(cleaned) + if err := s.host.StructuredQuota(userID, "canvas", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(cleaned))-existingBytes); err != nil { + return err + } + if err := SaveDocumentWithHistory(s.repo, existing, &project, reason); err != nil { + if errors.Is(err, repository.ErrCanvasRevisionConflict) { + return canvasRevisionConflict() + } + if errors.Is(err, repository.ErrCanvasHistoryResourceMissing) { + return kernel.NewAppError(http.StatusConflict, "画布引用的素材已变化,当前内容未被覆盖,请保留草稿并重新加载") + } + return err + } + audit.NodesAfter = canvasNodeCount(project.PayloadJSON) + if existing != nil { + audit.NodesBefore = canvasNodeCount(existing.PayloadJSON) + } if existingErr != nil || existing.PayloadJSON != project.PayloadJSON || existing.Title != project.Title { s.host.RecordActivity(userID, "canvas", 1) } @@ -230,47 +300,13 @@ func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (U if err != nil { return UserDataSummary{}, err } - return UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt}, nil + return UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, Revision: project.Revision, SaveAudit: &audit}, nil } func (s *Service) DeleteUserCanvasProject(userID string, id string) error { return s.repo.DeleteCanvasProject(userID, id) } -func (s *Service) ReplaceUserCanvasProjects(userID string, req CanvasProjectsSyncRequest) ([]json.RawMessage, error) { - projects := make([]model.CanvasProject, 0, len(req.Projects)) - var totalBytes int64 - for _, raw := range req.Projects { - item, err := canvasProjectFromJSON(userID, raw) - if err != nil { - return nil, err - } - projects = append(projects, item) - totalBytes += int64(len(raw)) - } - err := s.host.WithStorageLock(func() error { - for _, raw := range req.Projects { - if err := s.ValidateCanvasMediaAssets(userID, raw); err != nil { - return err - } - } - if err := s.host.StructuredReplacementQuota(userID, "canvas", len(projects), totalBytes); err != nil { - return err - } - if err := s.repo.ReplaceCanvasProjects(userID, projects); err != nil { - return err - } - if len(projects) > 0 { - s.host.RecordActivity(userID, "canvas", 1) - } - return nil - }) - if err != nil { - return nil, err - } - return s.UserCanvasProjects(userID) -} - func AssetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) { if err := ValidateSyncedPayload(raw, "素材"); err != nil { return model.Asset{}, err diff --git a/backend/internal/canvas/user_data_page.go b/backend/internal/canvas/user_data_page.go index a02f9b50d..9aef7e25d 100644 --- a/backend/internal/canvas/user_data_page.go +++ b/backend/internal/canvas/user_data_page.go @@ -40,6 +40,7 @@ type CanvasLibrarySummary struct { ID string `json:"id"` ProjectID string `json:"projectId,omitempty"` Title string `json:"title"` + Revision int64 `json:"revision"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` NodeCount int `json:"nodeCount"` @@ -107,7 +108,7 @@ func (s *Service) UserCanvasProjectsPage(userID string, page int, pageSize int, item["metadata"] = metadata preview = append(preview, item) } - result.Projects = append(result.Projects, CanvasLibrarySummary{ID: project.ID, ProjectID: project.ProjectID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, NodeCount: len(document.Nodes), PreviewNodes: preview}) + result.Projects = append(result.Projects, CanvasLibrarySummary{ID: project.ID, ProjectID: project.ProjectID, Title: project.Title, Revision: project.Revision, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, NodeCount: len(document.Nodes), PreviewNodes: preview}) } return result, nil } diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index e5af160b4..df22740c7 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 15 +const CurrentSchemaVersion int64 = 16 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -67,6 +67,9 @@ var schemaMigrations = []migration{ {version: 15, name: "agent_profiles", checksum: "sha256:agent-profiles-v15-20260914", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.AgentProfile{}) }}, + {version: 16, name: "canvas_revision_history", checksum: "sha256:canvas-revision-history-v16", apply: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.CanvasProject{}, &model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}) + }}, } func migrateSchemaV14(tx *gorm.DB) error { diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index 18d275eed..aa35d6727 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -61,11 +61,49 @@ func TestMigrateSchemaV15UpgradesExistingDatabase(t *testing.T) { t.Fatal("v15 upgrade did not install Agent profile table and scope index") } status, err := ReadSchemaStatus(db) - if err != nil || !status.Ready || status.Current != 15 { + if err != nil || !status.Ready || status.Current != CurrentSchemaVersion { t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err) } } +func TestMigrateSchemaV16BackfillsCanvasRevisions(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-canvas-revisions-v16?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + if err := db.Migrator().DropTable(&model.CanvasSnapshotResource{}, &model.CanvasSnapshot{}); err != nil { + t.Fatal(err) + } + if err := db.Migrator().DropColumn(&model.CanvasProject{}, "Revision"); err != nil { + t.Fatal(err) + } + if err := db.Exec(`INSERT INTO canvas_projects (id, user_id, title, payload_json) VALUES ('legacy', 'owner', 'Existing canvas', '{"nodes":[]}')`).Error; err != nil { + t.Fatal(err) + } + if err := db.Where("version = ?", 16).Delete(&schemaMigration{}).Error; err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + var project model.CanvasProject + if err := db.First(&project, "id = ?", "legacy").Error; err != nil { + t.Fatal(err) + } + if project.Revision != 1 || project.PayloadJSON != `{"nodes":[]}` { + t.Fatalf("legacy canvas changed: %+v", project) + } + if !db.Migrator().HasTable(&model.CanvasSnapshot{}) || !db.Migrator().HasTable(&model.CanvasSnapshotResource{}) { + t.Fatal("history tables missing") + } + if err := MigrateSchema(db); err != nil { + t.Fatalf("migration not idempotent: %v", err) + } +} + func TestMigrateSchemaV8AllowsReusingArchivedLogicalModelCode(t *testing.T) { db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-logical-model-active-code?mode=memory&cache=shared"}) if err != nil { diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index d2a7e6586..9f45f492c 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -84,6 +84,8 @@ func Models() []any { &model.WorkflowStepTask{}, &model.ProductionTaskLink{}, &model.CanvasProject{}, + &model.CanvasSnapshot{}, + &model.CanvasSnapshotResource{}, &model.CanvasShare{}, &model.PromptTemplate{}, &model.UserPromptCustomization{}, diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 91bf1e20d..bde1d7b43 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -18,12 +18,118 @@ tags: - name: meta - name: auth - name: projects + - name: canvas - name: resources - name: tasks - name: assets - name: agent - name: admin paths: + /canvas-projects/{id}: + parameters: + - in: path + name: id + required: true + schema: { type: string } + get: + tags: [canvas] + summary: 读取当前用户画布内容和 revision + responses: + "200": + description: 信封 data.project 包含完整画布及服务端 revision + "404": + description: 画布不存在或不属于当前用户 + put: + tags: [canvas] + summary: 按 revision 保存画布内容 + description: 新建传 revision 0,更新传读取到的版本;成功原子加 1。忽略客户端 viewport 和 remoteContentHash,updatedAt 由服务端生成。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [project] + properties: + project: + type: object + required: [id, revision] + properties: + id: { type: string } + revision: { type: integer, format: int64, minimum: 0 } + additionalProperties: true + responses: + "200": + description: 信封 data.project 返回摘要及递增后的 revision + "400": + description: 请求、版本或素材引用不合法 + "409": + description: 版本或资源状态冲突,原内容未被覆盖;保留草稿后加载最新版 + "428": + description: 缺少 revision,需刷新客户端并重新读取 + /canvas-projects/{id}/history: + get: + tags: [canvas] + summary: 读取当前画布版本与最近 20 份历史摘要 + parameters: + - in: path + name: id + required: true + schema: { type: string } + responses: + "200": + description: 信封 data 包含 currentRevision 和 snapshots;每项含 id、revision、title、nodeCount、connectionCount、payloadBytes、reason、contentUpdatedAt、createdAt,不含正文 + "404": + description: 画布不存在或无权访问 + /canvas-projects/{id}/history/{snapshotId}: + get: + tags: [canvas] + summary: 按需读取历史正文 + parameters: + - in: path + name: id + required: true + schema: { type: string } + - in: path + name: snapshotId + required: true + schema: { type: string } + responses: + "200": + description: 信封 data 包含 snapshot 摘要和 project 正文 + "404": + description: 画布或快照不存在、已过期或无权访问 + /canvas-projects/{id}/history/{snapshotId}/restore: + post: + tags: [canvas] + summary: 备份当前内容并将历史内容恢复为新版本 + parameters: + - in: path + name: id + required: true + schema: { type: string } + - in: path + name: snapshotId + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [revision] + properties: + revision: { type: integer, format: int64, minimum: 1 } + responses: + "200": + description: 信封 data.project 返回恢复后的摘要及新 revision;当前 ID、归属与项目关联保留 + "404": + description: 画布或快照不存在、已过期或无权访问 + "409": + description: 当前版本或素材状态已变化,恢复未执行 + "428": + description: 缺少当前 revision /public/welcome: get: tags: [meta] diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index ab822d131..e130d7dc7 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "strconv" "strings" @@ -631,6 +632,58 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"project": project}) }) + r.GET("/canvas-projects/:id/history", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + result, err := svc.CanvasHistory(user.ID, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.GET("/canvas-projects/:id/history/:snapshotId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + snapshot, err := svc.CanvasHistorySnapshot(user.ID, c.Param("id"), c.Param("snapshotId")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"snapshot": snapshot, "project": json.RawMessage(snapshot.PayloadJSON)}) + }) + r.POST("/canvas-projects/:id/history/:snapshotId/restore", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + policy, available := loadRuntimePolicy(c, svc) + if !available || !enforceRateLimit(c, "canvas-write:"+user.ID, policy.Request.CanvasWritePerMinute, time.Minute) { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1024) + var req struct { + Revision *int64 `json:"revision"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, service.BadAuthRequest("恢复请求格式错误")) + return + } + project, err := svc.RestoreCanvasHistory(user.ID, c.Param("id"), c.Param("snapshotId"), req.Revision) + if err != nil { + failService(c, err) + return + } + log.Printf("canvas_restore request_id=%q trace_id=%q actor=%q canvas=%q snapshot=%q base_revision=%d revision=%d", RequestID(c), TraceID(c), user.ID, c.Param("id"), c.Param("snapshotId"), *req.Revision, project.Revision) + ok(c, gin.H{"project": project}) + }) r.PUT("/canvas-projects/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -656,7 +709,25 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { fail(c, http.StatusBadRequest, service.BadAuthRequest("画布 ID 与请求路径不一致")) return } + var audit struct { + Revision *int64 `json:"revision"` + Nodes []json.RawMessage `json:"nodes"` + Connections []json.RawMessage `json:"connections"` + } + _ = json.Unmarshal(req.Project, &audit) + baseRevision := int64(-1) + if audit.Revision != nil { + baseRevision = *audit.Revision + } project, err := svc.UpsertUserCanvasProject(user.ID, req.Project) + defer func() { + nodesBefore, nodesAfter := -1, len(audit.Nodes) + if project.SaveAudit != nil { + nodesBefore, nodesAfter = project.SaveAudit.NodesBefore, project.SaveAudit.NodesAfter + } + // Metadata only: never log prompts, media URLs, cookies, or the canvas payload. + log.Printf("canvas_save request_id=%q trace_id=%q actor=%q canvas=%q base_revision=%d revision=%d nodes_before=%d nodes_after=%d connections=%d status=%d", RequestID(c), TraceID(c), user.ID, identity.ID, baseRevision, project.Revision, nodesBefore, nodesAfter, len(audit.Connections), c.Writer.Status()) + }() if err != nil { failService(c, err) return diff --git a/backend/internal/model/models_canvas_history.go b/backend/internal/model/models_canvas_history.go new file mode 100644 index 000000000..0df041d2a --- /dev/null +++ b/backend/internal/model/models_canvas_history.go @@ -0,0 +1,25 @@ +package model + +import "time" + +// CanvasSnapshot stores document structure only; media stays in Resource storage. +type CanvasSnapshot struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + CanvasID string `json:"canvasId" gorm:"size:80;not null;uniqueIndex:idx_canvas_snapshot_revision,priority:1;index:idx_canvas_snapshot_created,priority:1"` + UserID string `json:"-" gorm:"size:36;not null;index"` + Revision int64 `json:"revision" gorm:"not null;uniqueIndex:idx_canvas_snapshot_revision,priority:2"` + Title string `json:"title" gorm:"size:240"` + NodeCount int `json:"nodeCount"` + ConnectionCount int `json:"connectionCount"` + PayloadBytes int `json:"payloadBytes"` + PayloadJSON string `json:"-" gorm:"type:text;not null"` + Reason string `json:"reason" gorm:"size:24"` + ContentUpdatedAt time.Time `json:"contentUpdatedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"not null;index:idx_canvas_snapshot_created,priority:2"` +} + +type CanvasSnapshotResource struct { + SnapshotID string `json:"-" gorm:"primaryKey;size:36"` + ResourceID string `json:"-" gorm:"primaryKey;size:36;index"` + Resource *Resource `json:"-" gorm:"foreignKey:ResourceID;constraint:OnDelete:RESTRICT"` +} diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 608347281..c8feffd05 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -374,6 +374,7 @@ type CanvasProject struct { ProjectID string `json:"projectId,omitempty" gorm:"index;size:36;index:idx_canvas_projects_user_project_updated,priority:2"` Title string `json:"title" gorm:"size:240"` PayloadJSON string `json:"payloadJson" gorm:"type:text"` + Revision int64 `json:"revision" gorm:"not null;default:1"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_canvas_projects_user_updated,priority:2;index:idx_canvas_projects_user_project_updated,priority:3"` } diff --git a/backend/internal/repository/admin_storage_delete.go b/backend/internal/repository/admin_storage_delete.go index e45fc931a..23add4213 100644 --- a/backend/internal/repository/admin_storage_delete.go +++ b/backend/internal/repository/admin_storage_delete.go @@ -49,6 +49,9 @@ func (r *Repository) DeleteAdminResources(resources []model.Resource, deletionJo resourceIDs = append(resourceIDs, resource.ID) } return r.db.Transaction(func(tx *gorm.DB) error { + if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil { + return err + } var current []model.Resource query := tx.Where("id IN ?", resourceIDs) if r.Dialect() == "postgres" { diff --git a/backend/internal/repository/canvas_history.go b/backend/internal/repository/canvas_history.go new file mode 100644 index 000000000..96f68da55 --- /dev/null +++ b/backend/internal/repository/canvas_history.go @@ -0,0 +1,152 @@ +package repository + +import ( + "errors" + "slices" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ErrCanvasHistoryResourceMissing = errors.New("canvas history resource missing") +var ErrCanvasHistoryResourceReferenced = errors.New("resource referenced by canvas history") + +const canvasSnapshotSummaryColumns = "id, canvas_id, user_id, revision, title, node_count, connection_count, payload_bytes, reason, content_updated_at, created_at" + +func (r *Repository) CanvasProjectMetadata(userID, id string) (*model.CanvasProject, error) { + var project model.CanvasProject + err := r.db.Omit("payload_json").Where("id = ? AND user_id = ?", id, userID).First(&project).Error + return &project, err +} + +func (r *Repository) CanvasSnapshots(userID, canvasID string, limit int) ([]model.CanvasSnapshot, error) { + items := []model.CanvasSnapshot{} + err := r.db.Select(canvasSnapshotSummaryColumns).Where("user_id = ? AND canvas_id = ?", userID, canvasID). + Order("revision DESC").Limit(limit).Find(&items).Error + return items, err +} + +func (r *Repository) CanvasSnapshot(userID, canvasID, id string) (*model.CanvasSnapshot, error) { + var item model.CanvasSnapshot + err := r.db.Where("id = ? AND user_id = ? AND canvas_id = ?", id, userID, canvasID).First(&item).Error + return &item, err +} + +// The successful CAS takes the canvas row lock before inspecting the history. +// Snapshot creation, reference protection and retention commit with the content. +func (r *Repository) SaveCanvasWithSnapshot(project *model.CanvasProject, snapshot *model.CanvasSnapshot, resourceIDs, restoredResourceIDs []string, cutoff time.Time, limit int, force bool) error { + next := *project + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := New(tx).UpsertCanvasProject(&next); err != nil { + return err + } + if snapshot == nil { + return nil + } + var last model.CanvasSnapshot + err := tx.Select("id", "revision", "created_at").Where("canvas_id = ?", project.ID).Order("revision DESC").First(&last).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + capture := err != nil || (last.Revision != snapshot.Revision && (force || !last.CreatedAt.After(cutoff))) + requiredIDs := append([]string{}, restoredResourceIDs...) + if capture { + requiredIDs = append(requiredIDs, resourceIDs...) + } + slices.Sort(requiredIDs) + requiredIDs = slices.Compact(requiredIDs) + if len(requiredIDs) > 0 { + var resources []model.Resource + query := tx.Select("id").Where("id IN ? AND status = ?", requiredIDs, model.ResourceStatusReady).Order("id") + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.Find(&resources).Error; err != nil { + return err + } + if len(resources) != len(requiredIDs) { + return ErrCanvasHistoryResourceMissing + } + } + if !capture { + return nil + } + if err := tx.Create(snapshot).Error; err != nil { + return err + } + refs := make([]model.CanvasSnapshotResource, 0, len(resourceIDs)) + for _, id := range resourceIDs { + refs = append(refs, model.CanvasSnapshotResource{SnapshotID: snapshot.ID, ResourceID: id}) + } + if len(refs) > 0 { + if err := tx.CreateInBatches(refs, 200).Error; err != nil { + return err + } + } + var expired []string + if err := tx.Model(&model.CanvasSnapshot{}).Where("canvas_id = ?", project.ID).Order("revision DESC").Offset(limit).Limit(100).Pluck("id", &expired).Error; err != nil { + return err + } + return deleteCanvasSnapshots(tx, expired) + }) + if err == nil { + *project = next + } + return err +} + +func (r *Repository) CanvasHistoryReferencesObject(resource *model.Resource) (bool, error) { + var count int64 + aliases := r.db.Model(&model.Resource{}).Select("id").Where("endpoint = ? AND bucket = ? AND object_key = ?", resource.Endpoint, resource.Bucket, resource.ObjectKey) + err := r.db.Model(&model.CanvasSnapshotResource{}). + Where("resource_id = ? OR resource_id IN (?)", resource.ID, aliases). + Count(&count).Error + return count > 0, err +} + +func deleteCanvasSnapshots(tx *gorm.DB, ids []string) error { + if len(ids) == 0 { + return nil + } + if err := tx.Where("snapshot_id IN ?", ids).Delete(&model.CanvasSnapshotResource{}).Error; err != nil { + return err + } + return tx.Where("id IN ?", ids).Delete(&model.CanvasSnapshot{}).Error +} + +func (r *Repository) CanvasHistoryResourceReferences(resourceIDs []string) ([]ResourceDirectReference, error) { + refs := []ResourceDirectReference{} + if len(resourceIDs) == 0 { + return refs, nil + } + err := r.db.Table("canvas_snapshot_resources AS refs"). + Select("'画布历史版本' AS kind, snapshots.canvas_id AS id, snapshots.title, refs.resource_id"). + Joins("JOIN canvas_snapshots AS snapshots ON snapshots.id = refs.snapshot_id"). + Where("refs.resource_id IN ?", resourceIDs).Distinct().Scan(&refs).Error + return refs, err +} + +func (r *Repository) RequireNoCanvasHistoryReferences(resourceIDs []string) error { + if len(resourceIDs) == 0 { + return nil + } + var resources []model.Resource + query := r.db.Select("id").Where("id IN ?", resourceIDs).Order("id") + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.Find(&resources).Error; err != nil { + return err + } + refs, err := r.CanvasHistoryResourceReferences(resourceIDs) + if err != nil { + return err + } + if len(refs) > 0 { + return ErrCanvasHistoryResourceReferenced + } + return nil +} diff --git a/backend/internal/repository/canvas_history_test.go b/backend/internal/repository/canvas_history_test.go new file mode 100644 index 000000000..dffb39d01 --- /dev/null +++ b/backend/internal/repository/canvas_history_test.go @@ -0,0 +1,151 @@ +package repository + +import ( + "errors" + "fmt" + "sync" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +func testCanvasHistory(t *testing.T, db *gorm.DB) { + t.Helper() + if db.Dialector.Name() == "sqlite" { + if err := db.Exec("PRAGMA foreign_keys = ON").Error; err != nil { + t.Fatal(err) + } + } + if err := db.AutoMigrate(&model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}, &model.CanvasShare{}, &model.Task{}); err != nil { + t.Fatal(err) + } + repo := New(db) + now := time.Now().UTC().Truncate(time.Millisecond) + project := model.CanvasProject{ID: "history-canvas", UserID: "owner", Title: "original", PayloadJSON: `{"nodes":[{"id":"video"}]}`, UpdatedAt: now} + if err := repo.UpsertCanvasProject(&project); err != nil { + t.Fatal(err) + } + resource := model.Resource{ID: "history-video", UserID: "owner", Status: model.ResourceStatusReady} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + snapshot := func(p model.CanvasProject, at time.Time) *model.CanvasSnapshot { + return &model.CanvasSnapshot{ID: fmt.Sprintf("history-%d", p.Revision), CanvasID: p.ID, UserID: p.UserID, Revision: p.Revision, Title: p.Title, PayloadJSON: p.PayloadJSON, NodeCount: 1, CreatedAt: at} + } + save := func(at time.Time, force bool) { + t.Helper() + old := project + project.Title = fmt.Sprintf("edit-%d", old.Revision) + if err := repo.SaveCanvasWithSnapshot(&project, snapshot(old, at), []string{resource.ID}, nil, at.Add(-5*time.Minute), 20, force); err != nil { + t.Fatal(err) + } + } + save(now, false) + save(now.Add(4*time.Minute), false) + metadata, err := repo.CanvasProjectMetadata(project.UserID, project.ID) + if err != nil || metadata.Revision != project.Revision || metadata.UserID != project.UserID || metadata.PayloadJSON != "" { + t.Fatalf("canvas metadata: %#v %v", metadata, err) + } + items, err := repo.CanvasSnapshots("owner", project.ID, 20) + if err != nil || len(items) != 1 || items[0].PayloadJSON != "" { + t.Fatalf("summary/throttle: %#v %v", items, err) + } + save(now.Add(5*time.Minute), false) + items, _ = repo.CanvasSnapshots("owner", project.ID, 20) + if len(items) != 2 { + t.Fatalf("5 minute boundary = %d", len(items)) + } + if err := db.Transaction(func(tx *gorm.DB) error { + return New(tx).RequireNoCanvasHistoryReferences([]string{resource.ID}) + }); !errors.Is(err, ErrCanvasHistoryResourceReferenced) { + t.Fatalf("history protection = %v", err) + } + refs, err := repo.CanvasHistoryResourceReferences([]string{resource.ID}) + if err != nil || len(refs) == 0 || refs[0].Kind != "画布历史版本" { + t.Fatalf("references = %#v %v", refs, err) + } + if err := db.Delete(&resource).Error; err == nil { + t.Fatal("database allowed deletion of a history resource") + } + if _, err := repo.CanvasSnapshot("other", project.ID, items[0].ID); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("wrong owner = %v", err) + } + + // Snapshot failure must roll back the successful content CAS as well. + before := project + failed := project + failed.Title = "must rollback" + err = repo.SaveCanvasWithSnapshot(&failed, snapshot(project, now.Add(10*time.Minute)), []string{"missing"}, nil, now, 20, true) + if !errors.Is(err, ErrCanvasHistoryResourceMissing) { + t.Fatalf("missing resource = %v", err) + } + stored, _ := repo.CanvasProjectForUser("owner", project.ID) + if stored.Revision != before.Revision || stored.Title != before.Title || failed.Revision != before.Revision { + t.Fatal("failed snapshot changed content/revision") + } + + // Concurrent writers create exactly one history entry along with one new revision. + start := make(chan struct{}) + results := make(chan error, 2) + var workers sync.WaitGroup + for i := 0; i < 2; i++ { + workers.Add(1) + go func() { + defer workers.Done() + copy := project + <-start + results <- repo.SaveCanvasWithSnapshot(©, snapshot(project, now.Add(10*time.Minute)), []string{resource.ID}, nil, now, 20, true) + }() + } + close(start) + workers.Wait() + close(results) + success, conflict := 0, 0 + for err := range results { + if err == nil { + success++ + } else if errors.Is(err, ErrCanvasRevisionConflict) { + conflict++ + } else { + t.Fatal(err) + } + } + if success != 1 || conflict != 1 { + t.Fatalf("concurrent saves: success=%d conflict=%d", success, conflict) + } + stored, _ = repo.CanvasProjectForUser("owner", project.ID) + project = *stored + for i := 0; i < 23; i++ { + save(now.Add(time.Duration(20+i*5)*time.Minute), false) + } + var count, refCount int64 + db.Model(&model.CanvasSnapshot{}).Where("canvas_id = ?", project.ID).Count(&count) + db.Model(&model.CanvasSnapshotResource{}).Count(&refCount) + if count != 20 || refCount != 20 { + t.Fatalf("retention: snapshots=%d refs=%d", count, refCount) + } + save(now.Add(131*time.Minute), true) + items, _ = repo.CanvasSnapshots("owner", project.ID, 20) + if items[0].Revision != project.Revision-1 || len(items) != 20 { + t.Fatal("forced pre-restore backup missing or retention exceeded") + } + if err := repo.DeleteCanvasProject("owner", project.ID); err != nil { + t.Fatal(err) + } + db.Model(&model.CanvasSnapshot{}).Where("canvas_id = ?", project.ID).Count(&count) + db.Model(&model.CanvasSnapshotResource{}).Count(&refCount) + if count != 0 || refCount != 0 { + t.Fatal("canvas deletion left orphan history") + } + if err := db.Transaction(func(tx *gorm.DB) error { + return New(tx).RequireNoCanvasHistoryReferences([]string{resource.ID}) + }); err != nil { + t.Fatalf("expired reference not released: %v", err) + } + if err := db.Delete(&resource).Error; err != nil { + t.Fatalf("expired foreign key not released: %v", err) + } +} diff --git a/backend/internal/repository/canvas_revision_test.go b/backend/internal/repository/canvas_revision_test.go new file mode 100644 index 000000000..75a67f212 --- /dev/null +++ b/backend/internal/repository/canvas_revision_test.go @@ -0,0 +1,159 @@ +package repository + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestCanvasRevision(t *testing.T) { + t.Run("sqlite", func(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "canvas.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + testCanvasRevision(t, db) + }) + t.Run("postgres", func(t *testing.T) { + dsn := os.Getenv("CANVAS_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set CANVAS_TEST_POSTGRES_DSN to an isolated test database") + } + config := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)} + admin, err := gorm.Open(postgres.Open(dsn), config) + if err != nil { + t.Fatal(err) + } + schema := fmt.Sprintf("canvas_revision_%d", time.Now().UnixNano()) + if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error + connection, _ := admin.DB() + _ = connection.Close() + }) + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatal(err) + } + query := parsed.Query() + query.Set("search_path", schema) + parsed.RawQuery = query.Encode() + db, err := gorm.Open(postgres.Open(parsed.String()), config) + if err != nil { + t.Fatal(err) + } + connection, _ := db.DB() + t.Cleanup(func() { _ = connection.Close() }) + testCanvasRevision(t, db) + }) +} + +func testCanvasRevision(t *testing.T, db *gorm.DB) { + t.Helper() + if err := db.AutoMigrate(&model.CanvasProject{}, &model.CanvasUnitLink{}, &model.Project{}); err != nil { + t.Fatal(err) + } + repo := New(db) + initial := model.CanvasProject{ID: "canvas", UserID: "owner", Title: "initial", PayloadJSON: `{"nodes":[{"id":"old"}]}`} + if err := repo.UpsertCanvasProject(&initial); err != nil { + t.Fatal(err) + } + if initial.Revision != 1 { + t.Fatalf("initial revision = %d", initial.Revision) + } + + start := make(chan struct{}) + results := make(chan error, 2) + var workers sync.WaitGroup + for _, title := range []string{"editor A", "editor B"} { + workers.Add(1) + go func(title string) { + defer workers.Done() + copy := initial + copy.Title = title + copy.PayloadJSON = fmt.Sprintf(`{"nodes":[{"id":%q}]}`, title) + <-start + results <- repo.UpsertCanvasProject(©) + }(title) + } + close(start) + workers.Wait() + close(results) + successes, conflicts := 0, 0 + for err := range results { + if err == nil { + successes++ + } else if errors.Is(err, ErrCanvasRevisionConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if successes != 1 || conflicts != 1 { + t.Fatalf("successes=%d conflicts=%d", successes, conflicts) + } + winner, err := repo.CanvasProjectForUser("owner", initial.ID) + if err != nil { + t.Fatal(err) + } + if winner.Revision != 2 { + t.Fatalf("winner revision = %d", winner.Revision) + } + old := initial + old.UpdatedAt = time.Now().Add(time.Hour) + if err := repo.UpsertCanvasProject(&old); !errors.Is(err, ErrCanvasRevisionConflict) { + t.Fatalf("stale write = %v", err) + } + wrongOwner := *winner + wrongOwner.UserID = "another-user" + if err := repo.UpsertCanvasProject(&wrongOwner); !errors.Is(err, ErrCanvasRevisionConflict) { + t.Fatalf("wrong owner = %v", err) + } + createCollision := initial + createCollision.Revision = 0 + if err := repo.UpsertCanvasProject(&createCollision); !errors.Is(err, ErrCanvasRevisionConflict) { + t.Fatalf("create collision = %v", err) + } + stored, _ := repo.CanvasProjectForUser("owner", initial.ID) + if stored.PayloadJSON != winner.PayloadJSON || stored.Revision != 2 { + t.Fatal("rejected save changed the canvas") + } + + if err := repo.AssignCanvasToProject("owner", initial.ID, "project"); err != nil { + t.Fatal(err) + } + if err := repo.UpsertCanvasProject(winner); !errors.Is(err, ErrCanvasRevisionConflict) { + t.Fatalf("association did not invalidate old content: %v", err) + } + if err := repo.UnassignCanvasFromProject("owner", "project", initial.ID, `{"nodes":[]}`, time.Now(), 2); err == nil { + t.Fatal("stale unassignment replaced canvas content") + } + stored, _ = repo.CanvasProjectForUser("owner", initial.ID) + if stored.PayloadJSON != winner.PayloadJSON || stored.Revision != 3 { + t.Fatal("stale unassignment changed the canvas") + } + if err := db.Delete(&model.CanvasProject{}, "id = ?", initial.ID).Error; err != nil { + t.Fatal(err) + } + if err := repo.UpsertCanvasProject(stored); !errors.Is(err, ErrCanvasRevisionConflict) { + t.Fatalf("deleted canvas was recreated: %v", err) + } + t.Run("history", func(t *testing.T) { testCanvasHistory(t, db) }) +} diff --git a/backend/internal/repository/creation.go b/backend/internal/repository/creation.go index 906ab6c79..8afebd330 100644 --- a/backend/internal/repository/creation.go +++ b/backend/internal/repository/creation.go @@ -105,17 +105,7 @@ func (r *Repository) RevokeCreationSubmissions(runID string) error { return r.db.Model(&model.CreationSubmission{}).Where("run_id = ? AND task_id IS NULL AND revoked_at IS NULL", runID).Update("revoked_at", time.Now()).Error } func (r *Repository) CreateCreationCanvas(canvas *model.CanvasProject) error { - return r.db.Create(canvas).Error -} -func (r *Repository) CompareSaveCreationCanvas(canvas *model.CanvasProject, previous string) error { - result := r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND payload_json = ?", canvas.ID, canvas.UserID, previous).Updates(map[string]any{"payload_json": canvas.PayloadJSON, "title": canvas.Title, "updated_at": time.Now()}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return ErrCreationConflict - } - return nil + return r.UpsertCanvasProject(canvas) } // Capture server-side configuration versions, never channel credentials. diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 0b86dbbd3..096cb3f0a 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -35,6 +35,8 @@ var ErrProjectHasActiveTasks = errors.New("project has active tasks") var ErrProjectUnitShotsChanged = errors.New("project unit shots changed") +var ErrCanvasRevisionConflict = errors.New("canvas revision changed") + type Repository struct { db *gorm.DB } @@ -1152,7 +1154,7 @@ func (r *Repository) CanvasProjects(userID string) ([]model.CanvasProject, error func (r *Repository) CanvasProjectSummaries(userID string) ([]model.CanvasProject, error) { var projects []model.CanvasProject - err := r.db.Select("id", "title", "created_at", "updated_at").Order("updated_at desc").Find(&projects, "user_id = ?", userID).Error + err := r.db.Select("id", "title", "revision", "created_at", "updated_at").Order("updated_at desc").Find(&projects, "user_id = ?", userID).Error return projects, err } @@ -1165,17 +1167,51 @@ func (r *Repository) CanvasProjectForUser(userID string, id string) (*model.Canv } func (r *Repository) UpsertCanvasProject(project *model.CanvasProject) error { + expected := project.Revision + if expected < 0 { + return ErrCanvasRevisionConflict + } + if expected == 0 { + created := *project + created.Revision = 1 + result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&created) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrCanvasRevisionConflict + } + project.Revision = 1 + return nil + } + // The revision predicate and increment must be in the same SQL statement. + // A missing row is a conflict, never an invitation to recreate a deleted canvas. result := r.db.Model(&model.CanvasProject{}). - Where("id = ? AND user_id = ?", project.ID, project.UserID). - Updates(map[string]any{"project_id": project.ProjectID, "title": project.Title, "payload_json": project.PayloadJSON, "updated_at": project.UpdatedAt}) - if result.Error != nil || result.RowsAffected > 0 { + Where("id = ? AND user_id = ? AND revision = ?", project.ID, project.UserID, expected). + Updates(map[string]any{"project_id": project.ProjectID, "title": project.Title, "payload_json": project.PayloadJSON, "updated_at": project.UpdatedAt, "revision": expected + 1}) + if result.Error != nil { return result.Error } - return r.db.Create(project).Error + if result.RowsAffected != 1 { + return ErrCanvasRevisionConflict + } + project.Revision = expected + 1 + return nil } func (r *Repository) DeleteCanvasProject(userID string, id string) error { return r.db.Transaction(func(tx *gorm.DB) error { + // Serialize deletion with saves before reading the history IDs to remove. + if err := tx.Model(&model.CanvasProject{}).Where("user_id = ? AND id = ?", userID, id).UpdateColumn("revision", gorm.Expr("revision")).Error; err != nil { + return err + } + var snapshotIDs []string + if err := tx.Model(&model.CanvasSnapshot{}).Where("user_id = ? AND canvas_id = ?", userID, id).Pluck("id", &snapshotIDs).Error; err != nil { + return err + } + if err := deleteCanvasSnapshots(tx, snapshotIDs); err != nil { + return err + } if err := tx.Where("user_id = ? AND project_id = ?", userID, id).Delete(&model.CanvasShare{}).Error; err != nil { return err } @@ -1257,8 +1293,8 @@ func (r *Repository) DeleteProject(userID string, id string, canvasUpdates []mod } for _, canvas := range canvasUpdates { result := tx.Model(&model.CanvasProject{}). - Where("id = ? AND user_id = ? AND project_id = ?", canvas.ID, userID, id). - Updates(map[string]any{"project_id": "", "payload_json": canvas.PayloadJSON, "updated_at": canvas.UpdatedAt}) + Where("id = ? AND user_id = ? AND project_id = ? AND revision = ?", canvas.ID, userID, id, canvas.Revision). + Updates(map[string]any{"project_id": "", "payload_json": canvas.PayloadJSON, "updated_at": canvas.UpdatedAt, "revision": canvas.Revision + 1}) if result.Error != nil { return result.Error } @@ -1466,13 +1502,13 @@ func (r *Repository) UpsertCanvasUnitLink(link *model.CanvasUnitLink) error { func (r *Repository) ProjectCanvasSummaries(userID string, projectID string) ([]model.CanvasProject, error) { var canvases []model.CanvasProject - err := r.db.Select("id", "user_id", "project_id", "title", "created_at", "updated_at").Where("user_id = ? AND project_id = ?", userID, projectID).Order("updated_at desc").Find(&canvases).Error + err := r.db.Select("id", "user_id", "project_id", "title", "revision", "created_at", "updated_at").Where("user_id = ? AND project_id = ?", userID, projectID).Order("updated_at desc").Find(&canvases).Error return canvases, err } func (r *Repository) ProjectCanvasDocuments(userID string, projectID string) ([]model.CanvasProject, error) { var canvases []model.CanvasProject - err := r.db.Select("id", "title", "payload_json").Where("user_id = ? AND project_id = ?", userID, projectID).Find(&canvases).Error + err := r.db.Select("id", "title", "payload_json", "revision").Where("user_id = ? AND project_id = ?", userID, projectID).Find(&canvases).Error return canvases, err } @@ -1496,16 +1532,16 @@ func (r *Repository) DeleteCanvasUnitLink(projectID string, canvasID string, uni } func (r *Repository) AssignCanvasToProject(userID string, canvasID string, projectID string) error { - return r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", canvasID, userID).Update("project_id", projectID).Error + return r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", canvasID, userID).Updates(map[string]any{"project_id": projectID, "revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error } -func (r *Repository) UnassignCanvasFromProject(userID string, projectID string, canvasID string, payloadJSON string, updatedAt time.Time) error { +func (r *Repository) UnassignCanvasFromProject(userID string, projectID string, canvasID string, payloadJSON string, updatedAt time.Time, revision int64) error { return r.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("project_id = ? AND canvas_id = ?", projectID, canvasID).Delete(&model.CanvasUnitLink{}).Error; err != nil { return err } - result := tx.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND project_id = ?", canvasID, userID, projectID).Updates(map[string]any{ - "project_id": "", "payload_json": payloadJSON, "updated_at": updatedAt, + result := tx.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND project_id = ? AND revision = ?", canvasID, userID, projectID, revision).Updates(map[string]any{ + "project_id": "", "payload_json": payloadJSON, "updated_at": updatedAt, "revision": revision + 1, }) if result.Error != nil { return result.Error @@ -2351,18 +2387,6 @@ func (r *Repository) DeleteCanvasShare(userID string, projectID string) error { return r.db.Delete(&model.CanvasShare{}, "user_id = ? AND project_id = ?", userID, projectID).Error } -func (r *Repository) ReplaceCanvasProjects(userID string, projects []model.CanvasProject) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Delete(&model.CanvasProject{}, "user_id = ?", userID).Error; err != nil { - return err - } - if len(projects) == 0 { - return nil - } - return tx.Create(&projects).Error - }) -} - func (r *Repository) PromptTemplates() ([]model.PromptTemplate, error) { var templates []model.PromptTemplate err := r.db.Order("operation asc, version desc").Find(&templates).Error diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index 02e4cd381..831c1b9c1 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -86,6 +86,11 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s if len(resourceIDs) == 0 { return snapshot, nil } + history, err := r.CanvasHistoryResourceReferences(resourceIDs) + if err != nil { + return snapshot, err + } + snapshot.Direct = append(snapshot.Direct, history...) var assets []model.Asset assetQuery := r.db.Where("user_id = ? AND id <> ?", userID, excludingAssetID) @@ -322,6 +327,9 @@ func (r *Repository) AssetBusinessReferences(userID string, assetID string) ([]R func (r *Repository) DeleteAssetAndResources(userID string, assetID string, resourceIDs []string, deletionJobs []model.ResourceDeletionJob) error { return r.db.Transaction(func(tx *gorm.DB) error { + if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil { + return err + } versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", assetID) if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { return err diff --git a/backend/internal/service/aliases_types.go b/backend/internal/service/aliases_types.go index 41ac86d3c..d6ab4150d 100644 --- a/backend/internal/service/aliases_types.go +++ b/backend/internal/service/aliases_types.go @@ -74,7 +74,7 @@ type ( BulkDisableUsersResult = app.BulkDisableUsersResult CanvasLibraryPage = app.CanvasLibraryPage CanvasLibrarySummary = app.CanvasLibrarySummary - CanvasProjectsSyncRequest = app.CanvasProjectsSyncRequest + CanvasHistoryList = app.CanvasHistoryList CanvasShareRequest = app.CanvasShareRequest CanvasShareStatus = app.CanvasShareStatus CapabilityMatch = app.CapabilityMatch diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 920961652..5750c9feb 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -21,7 +21,7 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 15。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表。已发布版本的实现和校验和不得修改。 +当前结构版本为 16。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表;版本 16 `canvas_revision_history` 为既有画布回填 `revision = 1` 并新增历史快照与资源引用表。已发布版本的实现和校验和不得修改。 历史发布曾使用版本 6 `asset_library_folders`,之后合并引入了上述 6/7 顺序冲突。迁移器仅在该历史记录的名称和校验和均精确匹配时选择历史顺序:保留版本 6 的素材目录记录及时间,在版本 7 应用 `resource_playback_variant`,再继续版本 8。新数据库及已采用“6 播放副本、7 素材目录”的数据库保持原顺序;状态检查与启动校验采用同一套识别规则。未知名称、被改动的校验和或冲突的版本 7 记录仍会拒绝迁移,不通过删除、改名或伪造已应用记录绕过检查。 @@ -29,6 +29,20 @@ description: 影策后端核心持久化表与状态边界 版本 3 会将 `wardrobe`、`weapon`、`accessory` 迁移为 `prop`,将 `style` 迁移为 `material`,并把原先归入 `other` 的图片、视频、音频和 3D 模型迁移为 `material`。迁移只合并重复的待确认候选:保留最早记录,其余标记为 `ignored`;已经确认的资产不会被自动删除或归并。 +## 画布版本与快照 + +| 表 | 用途 | 关键边界 | +| --- | --- | --- | +| `canvas_projects` | 当前画布内容及递增 `revision`(非空,默认 1) | 客户端新建传 0;后续写入按用户、ID、原版本原子校验,成功才加 1 | +| `canvas_snapshots` | 结构 JSON、版本、标题、节点/连线数、字节数、内容时间和备份时间 | `(canvas_id, revision)` 唯一;列表只读摘要,正文按需加载;不复制媒体文件 | +| `canvas_snapshot_resources` | 历史快照引用的资源 | `(snapshot_id, resource_id)` 联合主键;资源外键禁止删除被引用记录 | + +浏览器、云端 Agent 和创作任务共用保存事务。内容变化时备份旧内容,普通备份最短间隔 5 分钟,每画布最多保留 20 份;恢复前强制备份当前内容。版本号每次成功写入递增,快照按间隔采样,两者不是一一对应。画布关联项目或解除关联同样推进版本,使旧页面的全量保存失效。 + +恢复历史保留当前画布 ID、归属和项目关联,按当前版本做 CAS 后形成新版本;不会把 revision 改回历史值。快照写入、引用保护、恢复和保留数量清理在同一事务内完成。资源删除检查与异步物理删除任务均检查历史引用(包括同对象的资源别名),仍被历史引用的媒体延后回收;删除画布时清理该画布快照及引用。 + +升级不改变已有画布正文,也不会凭空产生升级前历史。前后端应一起升级:旧客户端缺少 revision 的保存返回 428,需刷新客户端。 + ## 已部署 PostgreSQL 的素材 bytes 修复 升级到包含修复工具的镜像后,由部署管理员执行一次,无需终端用户修改数据库、清缓存或重建画布。旧镜像不支持以下子命令;请先将部署配置中的镜像版本更新为实际发布的修复版本。此操作不增加表或改变 schema 版本,也不会在启动时静默修改素材。 diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index 09f35d587..7c2cbefd2 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -32,6 +32,8 @@ description: 从产品功能快速定位主要代码入口 | 页面装配和跨域动作协调 | `web/src/pages/canvas/project.tsx` | | 画布领域规则和生成协议 | `web/src/lib/canvas/canvas-project-domain.ts`、`canvas-project-generation.ts` | | 项目恢复、持久化和项目级动作 | `web/src/pages/canvas/use-canvas-project-lifecycle.ts` | +| 云端版本校验与冲突草稿 | `web/src/services/user-data-sync.ts`、`canvas-sync-drafts.ts`、`web/src/lib/canvas/canvas-content.ts`、`web/src/pages/canvas/canvas-sync-status.tsx` | +| 历史树与独立只读预览 | `web/src/pages/canvas/canvas-version-history.tsx`、`canvas-version-preview.tsx`;后端 `internal/canvas/canvas_history.go` 与 `repository/canvas_history.go`,Agent 写入桥接在 `app/canvas_bridge.go` | | Patch 历史、生成任务和重试控制 | `web/src/pages/canvas/use-canvas-history.ts`、`use-canvas-generation.ts`、`use-canvas-generation-executor.ts`、`use-canvas-generation-retry.ts` | | 文本、图片、视频和音频生成执行 | `web/src/pages/canvas/canvas-*-generation-executor*.ts` | | 云端 Agent、助手面板、导演台和全局快捷键控制 | `web/src/components/canvas/canvas-cloud-agent-panel.tsx`、`web/src/pages/canvas/use-canvas-assistant-visibility.ts`、`use-canvas-director.ts`、`use-canvas-keyboard.ts`;画布操作契约在 `web/src/lib/canvas/canvas-operation-contract.ts` | diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 8bd3b05fb..2ae5b261c 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -9,6 +9,23 @@ description: 信封、错误码与 OpenAPI 机器可读规范:运行中的 `GET /api/openapi.yaml`,源文件 `backend/internal/handler/openapi.yaml`。前端 TypeScript 仍走 `web/src/services/api` 的 `http`,不从 OpenAPI 生成客户端。 +## 画布保存、历史与恢复 + +画布内容使用数据库递增的 `revision` 做乐观并发校验;`updatedAt` 仅用于展示,不作为版本依据。新建 `PUT /canvas-projects/:id` 的 `project.revision` 为 0,已有画布必须带上读取到的版本。数据库在同一次条件更新中匹配 ID、用户与版本,成功后返回 `data.project` 摘要及递增后的版本;缺失版本返回 428,陈旧版本返回 409,非法版本返回 400。客户端不能获取新版号后直接重发旧内容。 + +| 接口 | 行为 | +| --- | --- | +| `GET /canvas-projects/:id` | 返回当前内容及权威版本号 | +| `GET /canvas-projects/:id/history` | 返回 `snapshots` 摘要与 `currentRevision`,不批量读取历史正文 | +| `GET /canvas-projects/:id/history/:snapshotId` | 按需返回 `snapshot` 摘要和 `project` 历史正文 | +| `POST /canvas-projects/:id/history/:snapshotId/restore` | 请求 `{ revision }` 为当前画布版本,成功返回新版本摘要;恢复前强制备份当前内容 | + +历史接口先验证画布归属;无权访问、已删除画布或不存在的快照返回 404。恢复沿用画布写入限流和素材校验,在同一事务完成版本校验、备份、内容写入及历史资源保护。恢复保留当前画布 ID 和项目归属,生成新版本,不倒退版本号。升级前的内容无法补建历史。 + +平移和缩放只持久化浏览器视口,内容 PUT 忽略客户端 `viewport` 和 `remoteContentHash`。前端遇到 409/428 会保留账号隔离的本地草稿、暂停该画布自动提交,并提供加载最新版和下载草稿入口;载入最新版前草稿写入失败时停止替换。其他画布仍可保存。 + +云端 Agent 与创作任务的写入共用版本和历史事务;Agent `canvasPatch` 携带 `baseRevision`、`revision`,客户端只接受连续增量,跳过重复事件,缺口或同字段冲突时保留草稿并重新核对。删除、重排或顶层内容变化无法由增量完整表示时,事件携带 `requiresRefresh: true` 且不附 `canvasPatch`,客户端完整核对后才推进基线;撤销事件同样触发核对。保存日志仅记录请求/链路 ID、画布/用户 ID、版本、节点数量变化及 HTTP 状态,不记录正文、提示词或媒体 URL。 + ## 欢迎页开放配置 `GET /public/welcome` 无需登录,信封 `data` 仅包含布尔值 `welcomeEnabled`,响应禁用缓存。读取失败按标准错误信封返回。 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 07c1442e1..a4f1b9d1f 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -9,6 +9,16 @@ description: 当前项目已实现的核心工作台能力与使用入口 浮窗支持移动、调整大小和浏览器偏好恢复;后端支持逐轮对话、可信历史、取消、Skills 快照、画布摘要/精读、审批后的画布写入及媒体生成。终态旧轮次按其冻结合同保留为不可变历史,新消息使用当前合同创建新轮,不篡改父运行;旧合同下仍活动或待审批的运行不会被新版服务恢复执行。生成由预算和独立审批控制,画布写入使用内容快照哈希;未开放外部 MCP。系统行为策略以版本化资源发布,用户/项目/画布可编辑常驻偏好;偏好正文固定在运行快照并按层只读,不整段塞入 system prompt,也不能提权或跳过审批。节点创建、更新字段、摘要投影和连线限制来自服务端能力 registry;能力卡同时描述用途、适用场景、不适用场景、维护代价和可用动作,由 Agent 结合镜头数量、连续性、逐镜审查和交接需求自主选择,不强制所有任务使用分镜。分镜脚本支持结构化创建、分页读取及按真实行 ID 追加、修改和删除;普通文本和 Markdown 不会被宣称为具备镜头级维护能力的分镜表。图片、视频和音频节点可安全修改标题与下一版提示词草稿,既有媒体、已提交提示词和任务状态不会被草稿编辑覆盖;媒体模型按生成模式和真实参考节点从统一目录筛选。审批和执行记录明确展示目标节点、类型、修改字段、名称变化及连线两端,前端不再依靠内部 ID 猜测;这些预览不替代服务端归属、快照、Capability、锁、预算与调用哈希校验。真实模型计费与浏览器联调仍待验收。 +## 画布保存保护与版本记录 + +旧页面或旧缓存不能覆盖云端新内容。每次内容保存核对服务端版本,发生冲突会停止该画布自动提交并保留本地草稿;平移、缩放和打开画布不触发整份内容保存。画布顶部显示保存状态,可保留草稿后加载云端最新版。 + +「版本记录」在桌面右侧按日期展开历史树,小屏使用抽屉。点击版本进入独立只读预览,可以缩放查看或下载;返回当前画布后继续编辑。恢复历史会先保存本地草稿、备份当前云端内容,再创建一个新版本。历史只保存画布结构和媒体引用,不复制视频文件;普通快照最短间隔 5 分钟,每个画布保留最近 20 份,因此每次递增的版本号不一定都有快照。历史引用的媒体受到删除保护。本地绘图笔画不在云端历史中,草稿和历史导出不混入当前笔画。 + +此功能不提供多人实时协同或自动合并两个浏览器的完整画布;版本保护用于阻止静默覆盖。历史从升级后开始积累。 + +![右侧版本记录与独立只读画布预览(示例数据)](/images/canvas-version-history.png) + ## 画布智能引用 节点提示词面板提供“智能引用”入口,支持 AutoLink 开关和“一键引用全文”。输入已连接素材的名称、标签或素材栏序号后,按 Tab 或点击光标旁的候选项插入引用;全文引用会批量转换匹配到的指代,没有可转换内容时按钮禁用。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 1a61b474c..9b7669de8 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,18 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 画布保存保护与版本记录 + +已增加版本 CAS、冲突草稿、视口与内容分离、打开时核对云端、历史快照和恢复,以及右侧历史树和独立只读预览。上游云端 Agent 与创作任务使用同一版本和历史事务。专项测试覆盖并发旧写入、断档增量、草稿写入失败、恢复冲突、历史权限、保留数量和历史媒体删除保护。 + +隔离 SQLite/PostgreSQL 的迁移与版本/快照测试已通过;前端构建、类型检查与 lint 通过。真实 Chrome 的组件测试(合成数据与模拟接口)通过了明暗主题、桌面预览缩放、草稿查看、窄屏抽屉、恢复确认与无内容写请求检查;完整登录态和真实生成渠道仍按下列清单验收。 + +- [ ] 双标签页编辑同一画布:旧页平移/缩放不发内容 PUT,旧页内容保存收到冲突后保留草稿,另一画布仍可正常保存。 +- [ ] 在有未同步编辑时加载最新版或恢复历史,确认本地草稿可下载,恢复后版本递增且恢复前内容仍可找到。 +- [ ] 明暗主题、桌面和窄屏下检查右侧历史树、草稿页、预览缩放和恢复确认;预览期间快捷键和拖拽不能修改当前编辑器,返回当前画布后可继续生成和编辑。 +- [ ] 使用真实受控模型验收 Agent 生成任务完成与本地编辑同时发生时的冲突提示;自动化测试不调用收费渠道。 +- [ ] 既有部署执行 schema 16 迁移并刷新前端,确认旧画布正文保留;历史只从升级后积累,不能恢复升级前已被覆盖的内容。 + ## 画布节点快速引用 Agent - 多选节点后,选区浮动工具栏应显示 `@` 图标;点击「发送到 Agent」后,Agent 输入框应自动带入所有选中节点引用。 diff --git a/docs/public/images/canvas-version-history.png b/docs/public/images/canvas-version-history.png new file mode 100644 index 0000000000000000000000000000000000000000..691cdba25402aa032a245e0008626a3ea82e82bb GIT binary patch literal 84596 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 literal 0 HcmV?d00001 diff --git a/web/src/components/canvas/canvas-focus-mode-bar.tsx b/web/src/components/canvas/canvas-focus-mode-bar.tsx index 3a7ead0cd..ced941ac4 100644 --- a/web/src/components/canvas/canvas-focus-mode-bar.tsx +++ b/web/src/components/canvas/canvas-focus-mode-bar.tsx @@ -1,6 +1,7 @@ import { Tooltip } from "@/components/ui/base/tooltip"; +import type { ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; -import { PanelBottom, X, ZoomIn, ZoomOut } from "lucide-react"; +import { History, PanelBottom, X, ZoomIn, ZoomOut } from "lucide-react"; import { aceternityMotion } from "@/lib/aceternity-motion"; @@ -8,6 +9,9 @@ import { canvasThemes } from "@/lib/canvas-theme"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; type CanvasFocusModeBarProps = { + versionsOpen: boolean; + onToggleVersions: () => void; + syncStatus?: ReactNode; dockRevealed: boolean; zoomPercent: number; onToggleDock: () => void; @@ -17,7 +21,7 @@ type CanvasFocusModeBarProps = { onFit: () => void; }; -export function CanvasFocusModeBar({ dockRevealed, zoomPercent, onToggleDock, onExit, onZoomIn, onZoomOut, onFit }: CanvasFocusModeBarProps) { +export function CanvasFocusModeBar({ versionsOpen, onToggleVersions, syncStatus, dockRevealed, zoomPercent, onToggleDock, onExit, onZoomIn, onZoomOut, onFit }: CanvasFocusModeBarProps) { const theme = canvasThemes[useActiveTheme()]; const reducedMotion = useReducedMotion(); @@ -42,6 +46,12 @@ export function CanvasFocusModeBar({ dockRevealed, zoomPercent, onToggleDock, on + {syncStatus} + + + + + + {onOpenVersions ? ( + + ) : null} + + + } + > + + + + ); +} + +export function CanvasSyncDraftMenu({ projectId }: { projectId?: string }) { + const { message } = App.useApp(); + const [drafts, setDrafts] = useState([]); + const [draftScope, setDraftScope] = useState(""); + const [loading, setLoading] = useState(false); + const [exporting, setExporting] = useState(false); + return ( + { + if (!open) return; + const scope = getActiveUserScope(); + setDraftScope(scope); + setDrafts([]); + setLoading(true); + void (projectId ? readCanvasSyncDrafts(projectId, scope) : readAllCanvasSyncDrafts(scope)) + .then((items) => { + if (getActiveUserScope() === scope) setDrafts(items.reverse()); + }) + .catch(() => message.error("读取本地草稿失败")) + .finally(() => setLoading(false)); + }} + menu={{ + items: drafts.length + ? drafts.map((draft) => ({ + key: draft.id, + label: `${draft.project.title} · ${new Date(draft.savedAt).toLocaleString()}`, + onClick: () => { + if (getActiveUserScope() !== draftScope) { + setDrafts([]); + message.error("账号已切换,请重新打开本地草稿"); + return; + } + setExporting(true); + void exportCanvasProjects([draft.project], `${draft.project.title}-本地草稿`, { includeLocalDrawings: false }) + .then(() => message.success("草稿已下载,可从画布列表导入为新画布")) + .catch(() => message.error("草稿下载失败,请重试")) + .finally(() => setExporting(false)); + }, + })) + : [{ key: "empty", label: loading ? "正在读取草稿…" : "暂无本地草稿", disabled: true }], + }} + > + + + ); +} diff --git a/web/src/pages/canvas/canvas-version-history.css b/web/src/pages/canvas/canvas-version-history.css new file mode 100644 index 000000000..df8bda2f4 --- /dev/null +++ b/web/src/pages/canvas/canvas-version-history.css @@ -0,0 +1,250 @@ +.canvas-version-sidebar { + width: 350px; + flex: 0 0 350px; + min-height: 0; + border-left: 1px solid var(--border); +} +.canvas-editor-shell { + container-type: inline-size; +} +@container (max-width: 1050px) { + .canvas-topbar-tools-cluster > :not(:has(.canvas-topbar-version-button)):not(.canvas-topbar-agent-button) { + display: none !important; + } +} +@container (max-width: 650px) { + .canvas-topbar-project-picker { + display: none !important; + } + .canvas-topbar-project-cluster { + max-width: calc(100% - 180px); + } + .canvas-topbar { + gap: 8px; + padding-inline: 10px; + } + .canvas-sync-status-label { + display: none; + } + .canvas-topbar-project-context { + display: none; + } +} +.canvas-version-panel { + height: 100%; + display: flex; + flex-direction: column; + background: var(--sidebar); + color: var(--foreground); + font-size: var(--fs-label); + overscroll-behavior: contain; +} +.canvas-version-header { + display: flex; + align-items: center; + gap: 10px; + min-height: 64px; + padding: 0 18px; +} +.canvas-version-header h2 { + flex: 1; + font-size: var(--fs-body); + font-weight: 600; +} +.canvas-version-tabs { + display: flex; + margin: 0 18px 14px; + padding: 3px; + gap: 3px; + background: var(--muted); + border-radius: var(--radius-lg); +} +.canvas-version-tabs button { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + gap: 6px; + padding: 9px 4px; + color: var(--muted-foreground); + border-radius: var(--radius-md); +} +.canvas-version-tabs button[aria-selected="true"] { + background: var(--background); + color: var(--foreground); +} +.canvas-version-count, +.canvas-version-tag { + font-size: var(--fs-tiny); + color: var(--muted-foreground); + font-variant-numeric: tabular-nums; +} +.canvas-version-tag { + border-radius: var(--radius-sm); + background: var(--muted); + padding: 2px 6px; + white-space: nowrap; +} +.canvas-version-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 18px 20px; + overscroll-behavior: contain; +} +.canvas-version-current { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + text-align: left; + border: 1px solid var(--border); + padding: 12px; + border-radius: var(--radius-lg); +} +.canvas-version-current > span:nth-child(2) { + flex: 1; + min-width: 0; +} +.canvas-version-current strong, +.canvas-version-current small { + display: block; +} +.canvas-version-dot { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border: 1px solid var(--border); + border-radius: 50%; +} +.canvas-version-current[aria-pressed="true"], +.canvas-version-item[aria-pressed="true"] { + background: var(--accent); + border-color: var(--control-focus-ring); +} +.canvas-version-panel small { + display: block; + margin-top: 5px; + color: var(--muted-foreground); + font-size: var(--fs-tiny); + line-height: 1.6; +} +.canvas-version-hint { + color: var(--muted-foreground); + font-size: var(--fs-tiny); + line-height: 1.8; + margin: 10px 0; +} +.canvas-version-error { + color: var(--destructive); + line-height: 1.8; + margin: 10px 0; +} +.canvas-version-empty { + padding: 35px 0; + text-align: center; +} +.canvas-version-group { + margin-top: 15px; +} +.canvas-version-group summary { + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 7px; + color: var(--muted-foreground); + padding: 6px 0; +} +.canvas-version-group summary::-webkit-details-marker { + display: none; +} +.canvas-version-group:not([open]) summary > svg { + transform: rotate(-90deg); +} +.canvas-version-group summary > span:nth-child(2) { + flex: 1; +} +.canvas-version-branches { + border-left: 1px solid var(--border); + margin: 6px 0 0 7px; + padding-left: 16px; +} +.canvas-version-item { + position: relative; + width: 100%; + display: block; + text-align: left; + padding: 12px; + border: 1px solid transparent; + border-radius: var(--radius-lg); + margin-bottom: 6px; +} +.canvas-version-branches .canvas-version-item::before { + content: ""; + position: absolute; + left: -21px; + top: 19px; + width: 7px; + height: 7px; + border: 1px solid var(--muted-foreground); + border-radius: 50%; + background: var(--sidebar); +} +.canvas-version-branches .canvas-version-item[aria-pressed="true"]::before { + background: var(--foreground); + border-color: var(--foreground); +} +.canvas-version-item:hover, +.canvas-version-current:hover { + background: var(--accent); +} +.canvas-version-item-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; +} +.canvas-version-item-title > .canvas-version-tag { + margin-left: auto; +} +.canvas-version-reason { + display: block; + margin-top: 8px; +} +.canvas-version-draft-note { + color: var(--muted-foreground); + line-height: 1.8; + margin: 0 0 15px; +} +.canvas-version-draft { + border-color: var(--border); + margin-bottom: 10px; +} +.canvas-version-draft time { + display: block; + color: var(--muted-foreground); + font-size: var(--fs-tiny); + margin-top: 10px; +} +.canvas-version-footer { + flex-shrink: 0; + border-top: 1px solid var(--border); + padding: 16px 18px; +} +.canvas-version-actions { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 14px; +} +.canvas-version-panel button:focus-visible, +.canvas-version-group summary:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} +.canvas-version-panel button:disabled { + cursor: not-allowed; + opacity: 0.5; +} diff --git a/web/src/pages/canvas/canvas-version-history.tsx b/web/src/pages/canvas/canvas-version-history.tsx new file mode 100644 index 000000000..da259d914 --- /dev/null +++ b/web/src/pages/canvas/canvas-version-history.tsx @@ -0,0 +1,429 @@ +import { AppDrawer } from "@/components/ui/product/app-drawer"; +import { EmptyState } from "@/components/ui/product/empty-state"; +import { App, Button, Grid, Spin } from "antd"; +import { Check, ChevronDown, Cloud, Download, FileClock, History, RefreshCw, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getCanvasHistoryEntry, listCanvasHistory, type CanvasHistoryEntry } from "@/services/api/user-data"; +import { readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts"; +import { exportCanvasProjects } from "@/lib/canvas/canvas-export"; +import { getActiveUserScope } from "@/lib/user-scope"; +import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; +import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; +import "./canvas-version-history.css"; + +export type CanvasVersionPreviewState = { + key: string; + label: string; + date: string; + kind: "cloud" | "draft"; + snapshot?: CanvasHistoryEntry; + project?: CanvasProject; + error?: string; +}; + +export function useCanvasVersionHistory(projectId: string, onRestore: (snapshotId: string, revision: number) => Promise) { + const { message, modal } = App.useApp(); + const desktop = Boolean(Grid.useBreakpoint().lg); + const scope = getActiveUserScope(); + const contextRef = useRef({ projectId, scope }); + contextRef.current = { projectId, scope }; + const isCurrentContext = () => contextRef.current.projectId === projectId && contextRef.current.scope === scope && getActiveUserScope() === scope; + const draftCount = useSyncProgressStore((state) => state.syncingProjects[projectId]?.draftCount); + const [open, setOpen] = useState(false); + const [listOpen, setListOpen] = useState(true); + const [tab, setTab] = useState<"cloud" | "draft">("cloud"); + const [entries, setEntries] = useState([]); + const [drafts, setDrafts] = useState([]); + const [currentRevision, setCurrentRevision] = useState(); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [draftLoading, setDraftLoading] = useState(false); + const [restoring, setRestoring] = useState(false); + const [confirming, setConfirming] = useState(false); + const [exporting, setExporting] = useState(false); + const [error, setError] = useState(""); + const [draftError, setDraftError] = useState(""); + const [reload, setReload] = useState(0); + const previewRequest = useRef(null); + const returnToCurrent = useCallback(() => { + previewRequest.current?.abort(); + setPreview(null); + }, []); + const close = useCallback(() => { + if (!restoring) { + setOpen(false); + returnToCurrent(); + } + }, [restoring, returnToCurrent]); + + useEffect(() => { + setOpen(false); + setTab("cloud"); + returnToCurrent(); + }, [projectId, scope, returnToCurrent]); + + useEffect(() => { + if (!open) return; + const controller = new AbortController(); + setLoading(true); + setError(""); + setEntries([]); + setCurrentRevision(undefined); + void listCanvasHistory(projectId, controller.signal) + .then((result) => { + if (controller.signal.aborted || !isCurrentContext()) return; + setEntries(result.snapshots); + // Keep the revision observed with this list as the restore precondition. + setCurrentRevision(result.currentRevision); + }) + .catch((cause) => { + if (!controller.signal.aborted && isCurrentContext()) setError(cause instanceof Error ? cause.message : "历史版本读取失败"); + }) + .finally(() => { + if (!controller.signal.aborted && isCurrentContext()) setLoading(false); + }); + return () => controller.abort(); + }, [open, projectId, reload, scope]); + + useEffect(() => { + if (!open) return; + let active = true; + setDraftLoading(true); + setDraftError(""); + setDrafts([]); + void readCanvasSyncDrafts(projectId, scope) + .then((items) => { + if (active && isCurrentContext()) setDrafts(items.sort((a, b) => b.savedAt.localeCompare(a.savedAt))); + }) + .catch(() => { + if (active && isCurrentContext()) setDraftError("本地草稿读取失败,请刷新重试"); + }) + .finally(() => { + if (active && isCurrentContext()) setDraftLoading(false); + }); + return () => { + active = false; + }; + }, [open, projectId, reload, scope, draftCount]); + + useEffect( + () => () => { + previewRequest.current?.abort(); + }, + [open, projectId, scope], + ); + + const selectVersion = (entry: CanvasHistoryEntry) => { + previewRequest.current?.abort(); + const controller = new AbortController(); + previewRequest.current = controller; + const selection: CanvasVersionPreviewState = { key: entry.id, label: `版本 ${entry.revision}`, date: entry.contentUpdatedAt, kind: "cloud", snapshot: entry }; + setPreview(selection); + setListOpen(false); + void getCanvasHistoryEntry(projectId, entry.id, controller.signal) + .then(({ project }) => { + if (!controller.signal.aborted && previewRequest.current === controller && isCurrentContext()) setPreview({ ...selection, project }); + }) + .catch((cause) => { + if (!controller.signal.aborted && previewRequest.current === controller && isCurrentContext()) setPreview({ ...selection, error: cause instanceof Error ? cause.message : "预览读取失败,请重新选择版本" }); + }); + }; + + const restore = () => { + const selected = preview?.snapshot; + if (!selected || !preview.project || currentRevision === undefined || restoring) return; + setConfirming(true); + modal.confirm({ + title: `恢复版本 ${selected.revision} 的内容?`, + content: "恢复前会备份当前云端内容,并保留本地草稿。恢复后会生成一个新版本,画布的项目归属保持当前设置。", + okText: "恢复此版本", + cancelText: "取消", + afterClose: () => setConfirming(false), + onOk: async () => { + if (!isCurrentContext()) { + message.error("画布或账号已切换,请重新打开版本记录"); + return; + } + setRestoring(true); + try { + await onRestore(selected.id, currentRevision); + returnToCurrent(); + setReload((value) => value + 1); + message.success("已恢复内容并保存为新版本"); + } catch (cause) { + const detail = cause instanceof Error ? cause.message : "恢复失败,请重试"; + setError(detail); + message.error(detail); + } finally { + setRestoring(false); + } + }, + }); + }; + + const download = async () => { + if (!preview?.project || !isCurrentContext()) return; + setExporting(true); + try { + // Drawing strokes are not versioned; never mix today's local strokes into an old snapshot. + await exportCanvasProjects([preview.project], `${preview.project.title}-${preview.label}`, { includeLocalDrawings: false }); + message.success("已下载,可从画布列表导入为新画布"); + } catch (cause) { + message.error(cause instanceof Error ? cause.message : "下载失败,请重试"); + } finally { + setExporting(false); + } + }; + + return { + open, + listOpen, + desktop, + tab, + entries, + drafts, + currentRevision, + preview, + loading, + draftLoading, + restoring, + confirming, + exporting, + error, + draftError, + show: () => { + setOpen(true); + setListOpen(true); + }, + toggle: () => { + if (open && (desktop || listOpen)) close(); + else { + setOpen(true); + setListOpen(true); + } + }, + hideList: () => { + if (!restoring) { + setListOpen(false); + if (!preview) setOpen(false); + } + }, + close, + changeTab: (next: "cloud" | "draft") => { + setTab(next); + returnToCurrent(); + }, + refresh: () => { + returnToCurrent(); + setReload((value) => value + 1); + }, + selectVersion, + selectDraft: (draft: CanvasSyncDraft) => { + previewRequest.current?.abort(); + setPreview({ key: draft.id, label: "本地草稿", date: draft.savedAt, kind: "draft", project: draft.project }); + setListOpen(false); + }, + returnToCurrent, + restore, + download, + }; +} + +export type CanvasVersionHistoryController = ReturnType; + +export function CanvasVersionHistory({ history }: { history: CanvasVersionHistoryController }) { + const { open, tab, entries, drafts, currentRevision, preview, loading, draftLoading, restoring, exporting, error, draftError } = history; + if (!open) return null; + const groups = new Map(); + for (const entry of entries) { + const day = new Date(entry.contentUpdatedAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" }); + groups.set(day, [...(groups.get(day) || []), entry]); + } + const content = ( +
+
+ +

版本记录

+
+
{ + if (restoring || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const next = event.key === "Home" ? "cloud" : event.key === "End" ? "draft" : tab === "cloud" ? "draft" : "cloud"; + history.changeTab(next); + event.currentTarget.querySelector(`[data-tab="${next}"]`)?.focus(); + }} + > + + +
+
+ {tab === "cloud" ? ( + <> + +

查看旧版不会修改当前画布

+ {error ? ( +

+ {error} +

+ ) : null} + {loading ? ( +
+ +
+ ) : !entries.length && !error ? ( + + ) : null} + {[...groups].map(([day, items]) => ( +
+ + + {day} + {items.length} + +
+ {items.map((entry) => ( + + ))} +
+
+ ))} + + ) : ( + <> +

发生冲突或恢复版本时保留的本机备份。下载后可从画布列表导入为新画布。

+ {draftError ? ( +

+ {draftError} +

+ ) : null} + {draftLoading ? ( +
+ +
+ ) : !drafts.length && !draftError ? ( + + ) : null} + {drafts.map((draft) => ( + + ))} + + )} +
+
+ {preview ? ( + <> +
+ {preview.label} + 只读预览 +
+ {new Date(preview.date).toLocaleString("zh-CN")} + {preview.project ? ( + + {preview.project.nodes.length} 个节点 · {preview.project.connections.length} 条连线 + + ) : null} + {preview.project?.nodes.some((node) => node.type === "drawing") ?

绘图仅保留已上传的预览,不含本机历史笔画。

: null} +
+ {preview.kind === "cloud" ? ( + + ) : null} + +
+ + ) : ( +

{tab === "cloud" ? "内容变化时最多每 5 分钟保留一份,保留最近 20 份。恢复前额外备份。" : "草稿仅存于此浏览器,建议及时下载保留。"}

+ )} +
+
+ ); + + return history.desktop ? ( + + ) : ( + + {content} + + ); +} + +function formatTime(date: string) { + return new Date(date).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); +} diff --git a/web/src/pages/canvas/canvas-version-preview.tsx b/web/src/pages/canvas/canvas-version-preview.tsx new file mode 100644 index 000000000..d592c030a --- /dev/null +++ b/web/src/pages/canvas/canvas-version-preview.tsx @@ -0,0 +1,230 @@ +import { EmptyState } from "@/components/ui/product/empty-state"; +import { Button, Spin } from "antd"; +import { ArrowLeft, Eye, History, Maximize, Minus, Plus } from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; +import { CanvasNode } from "@/components/canvas/canvas-node"; +import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node"; +import { ConnectionPath } from "@/components/canvas/canvas-connections"; +import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context"; +import { CanvasNodeGraphContext } from "@/components/canvas/canvas-node-graph-context"; +import { isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame"; +import { getCanvasNodesBounds, viewportAtScale, viewportForBounds } from "@/lib/canvas/canvas-viewport"; +import { resolveCanvasAppearance } from "@/lib/canvas/canvas-appearance"; +import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; +import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; +import { resolveMediaUrl } from "@/services/file-storage"; +import type { CanvasNodeData, ViewportTransform } from "@/types/canvas"; +import type { CanvasVersionPreviewState } from "./canvas-version-history"; + +const noAction = () => undefined; +const readOnlyActions = {}; +const previewNodeId = (id: string) => `version-preview:${id}`; + +export function CanvasVersionPreview({ preview, onReturn, onShowVersions }: { preview: CanvasVersionPreviewState; onReturn: () => void; onShowVersions: () => void }) { + const containerRef = useRef(null); + const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); + const [selectedId, setSelectedId] = useState(); + const [localMedia, setLocalMedia] = useState>({}); + const [mediaError, setMediaError] = useState(""); + const theme = useActiveTheme(); + const project = preview.project; + useEffect(() => { + const node = project?.nodes.find((item) => item.id === selectedId); + const key = node?.metadata?.storageKey; + if (!node || !key || resourceIdFromStorageKey(key) || !["video", "audio"].includes(node.type)) return; + let active = true; + setMediaError(""); + void resolveMediaUrl(key, node.metadata?.content) + .then((url) => { + if (active) setLocalMedia((current) => ({ ...current, [node.id]: url })); + }) + .catch(() => { + if (active) setMediaError("本机媒体读取失败,可下载草稿后检查素材"); + }); + return () => { + active = false; + }; + }, [project, selectedId]); + const nodes = useMemo( + () => + (project?.nodes || []).map((node) => { + const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey); + return resourceId && ["image", "video", "audio"].includes(node.type) + ? { ...node, metadata: { ...node.metadata, content: resourceFileUrl(resourceId) } } + : localMedia[node.id] + ? { ...node, metadata: { ...node.metadata, content: localMedia[node.id] } } + : node; + }), + [project, localMedia], + ); + const visibleNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]); + const connections = useMemo( + () => + (project?.connections || []).flatMap((connection) => { + const resolved = resolveFrameConnection(connection, nodes); + return resolved ? [{ connection, ...resolved }] : []; + }), + [project, nodes], + ); + const graph = useMemo(() => { + const byId = new Map(nodes.map((node) => [node.id, node])); + return { + getUpstreamNodes: (id: string) => + (project?.connections || []) + .filter((connection) => previewNodeId(connection.toNodeId) === id) + .flatMap((connection) => { + const node = byId.get(connection.fromNodeId); + return node ? [node] : []; + }), + }; + }, [nodes, project]); + const fit = useCallback(() => { + const rect = containerRef.current?.getBoundingClientRect(); + const sourceNodes = project?.nodes || []; + const bounds = getCanvasNodesBounds(sourceNodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, sourceNodes))); + if (rect && bounds) setViewport(viewportForBounds(bounds, rect, { padding: 72 })); + }, [project]); + useLayoutEffect(() => { + fit(); + }, [fit]); + const zoom = (scale: number) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) setViewport((current) => viewportAtScale(current, rect, scale)); + }; + + return ( +
+
+ +
+ {preview.label} · 只读预览 +

{new Date(preview.date).toLocaleString("zh-CN")}

+
+ + +
+ {preview.error ? ( +
+

{preview.error}

+ +
+ ) : !project ? ( +
+ +
+ +
+ ) : ( +
+ {/* No editor callbacks, drawing cache key, or action context may cross this boundary. */} + + + event.preventDefault()} + onDrop={(event) => event.preventDefault()} + onCanvasDeselect={() => setSelectedId(undefined)} + > + + {connections.map(({ connection, from, to }) => ( + + ))} + + {visibleNodes.map((node) => + isFrameNode(node) ? ( + child.parentId === node.id)} + scale={viewport.k} + isSelected={false} + isDropTarget={false} + readOnly + onMouseDown={noAction} + onResize={noAction} + onToggleCollapsed={noAction} + onFolderStyleChange={noAction} + onTitleChange={noAction} + onContextMenu={(event) => event.preventDefault()} + /> + ) : ( + setSelectedId(node.id)} + onHoverStart={noAction} + onHoverEnd={noAction} + onConnectStart={noAction} + onResize={noAction} + onContentChange={noAction} + onContextMenu={(event) => event.preventDefault()} + /> + ), + )} + + + + {!nodes.length ? ( +
+ +
+ ) : null} + {mediaError ? ( +

+ {mediaError} +

+ ) : null} +
+
+
+ )} +
+ ); +} + +function renderReadOnlyContent(node: CanvasNodeData) { + const rows = node.metadata?.storyboard?.rows; + return ( +
+ {node.title} +
+ {rows?.length + ? rows.map((row) => ( +
+

+ 镜头 {row.shotNumber} · {row.durationSeconds}s +

+

{row.plotDescription}

+

{row.dialogue}

+
+ )) + : node.metadata?.content || node.metadata?.composerContent || node.metadata?.prompt || "此节点没有文本内容"} +
+
+ ); +} diff --git a/web/src/pages/canvas/index.tsx b/web/src/pages/canvas/index.tsx index 8cdfa0159..b016b3239 100644 --- a/web/src/pages/canvas/index.tsx +++ b/web/src/pages/canvas/index.tsx @@ -29,6 +29,7 @@ import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/servi import { primeResourceBlobCache } from "@/services/resource-blob-cache"; import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; +import { CanvasSyncDraftMenu } from "./canvas-sync-status"; import { useAppearanceStore } from "@/stores/use-appearance-store"; const CanvasDeleteProjectsDialog = lazy(() => import("@/components/canvas/canvas-delete-projects-dialog").then((module) => ({ default: module.CanvasDeleteProjectsDialog }))); @@ -358,15 +359,21 @@ export default function CanvasPage() { await flushCanvasStorePersistence(); if (remoteSyncEnabled) { try { - await saveRemoteUserDataNow(); + await saveRemoteUserDataNow(importedProjectId); } catch (syncError) { remoteSyncWarning ||= syncError; scheduleRemoteUserDataSync(); console.warn("导入画布云端同步失败,等待自动重试", syncError); } } + } catch (error) { + useSyncProgressStore.getState().setProjectProgress(importedProjectId, { + phase: "error", + message: error instanceof Error ? error.message : "画布导入未完成", + }); + throw error; } finally { - useSyncProgressStore.getState().setProjectProgress(importedProjectId, null); + if (!remoteSyncEnabled) useSyncProgressStore.getState().setProjectProgress(importedProjectId, null); } } @@ -409,6 +416,7 @@ export default function CanvasPage() { meta={{totalProjects} 个} actions={
+ diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 5656afe49..25be33087 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -100,6 +100,9 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback"; import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation"; import { cancelGenerationTask } from "@/services/api/task-center"; +import { CanvasSyncStatus } from "./canvas-sync-status"; +import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history"; +import { CanvasVersionPreview } from "./canvas-version-preview"; import { CanvasTopBar } from "./canvas-project-top-bar"; import { LibTVImportDialog } from "./components/libtv-import-dialog"; import { TapNowImportDialog } from "./components/tapnow-import-dialog"; @@ -333,17 +336,9 @@ function InfiniteCanvasPage() { const [titleDraft, setTitleDraft] = useState(""); const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0); const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false); - const { assistantOpen, closeAgent, openAgent } = useCanvasAssistantVisibility(); + const { assistantOpen, closeAgent, openAgent: openAssistant } = useCanvasAssistantVisibility(); const agentMentionReferences = useMemo(() => buildCanvasAgentMentionReferences(nodes), [nodes]); - const sendSelectionToAgent = useCallback((nodeId?: string) => { - const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current); - const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference)); - if (!references.length) return; - setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `); - openAgent(); - setContextMenu(null); - }, [agentMentionReferences, openAgent]); const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded); const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode(); const [focusDockRevealed, setFocusDockRevealed] = useState(false); @@ -427,7 +422,7 @@ function InfiniteCanvasPage() { [cleanupAssetImages, getHistoryCleanupContext], ); - const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({ + const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, reloadLatestCanvasProject, restoreCanvasProjectVersion, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({ projectId, projectLoaded, nodes, @@ -440,6 +435,8 @@ function InfiniteCanvasPage() { viewport, nodesRef, connectionsRef, + chatSessionsRef, + activeChatIdRef, viewportRef, historyPausedRef, setNodes, @@ -456,13 +453,24 @@ function InfiniteCanvasPage() { cleanupCanvasFiles, }); - // 强制覆盖会改写云端版本并重绑媒体素材关联,必须让用户显式确认 destructive 语义。 + const versions = useCanvasVersionHistory(projectId, restoreCanvasProjectVersion); + const openVersions = () => { closeAgent(); setVersionCompareRootId(null); versions.show(); }; + const openAgent = useCallback(() => { versions.close(); openAssistant(); }, [versions.close, openAssistant]); + + const sendSelectionToAgent = useCallback((nodeId?: string) => { + const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current); + const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference)); + if (!references.length) return; + setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `); + openAgent(); + setContextMenu(null); + }, [agentMentionReferences, openAgent]); + // 修复素材关联仍遵守当前画布版本,不能替用户确认覆盖云端的新内容。 const confirmForceSaveCanvas = useCallback(() => { modal.confirm({ - title: "用本地内容强制覆盖云端?", - content: "将把当前本地画布保存并覆盖云端版本,同时自动修复画布媒体与素材库的绑定(缺少素材记录时会按节点新建)。云端尚未同步到本地的改动会被覆盖。", - okText: "强制覆盖保存", - okButtonProps: { danger: true }, + title: "修复素材关联并保存?", + content: "核对画布媒体与素材库的关联,补齐缺失素材后保存。若云端已有新版本,会保留本地草稿并提示加载最新版。", + okText: "修复并保存", cancelText: "取消", onOk: () => forceSaveCanvasProject(), }); @@ -478,7 +486,7 @@ function InfiniteCanvasPage() { connectionsRef.current = nextConnections; setNodes(nextNodes); setConnections(nextConnections); - const saved = await saveCanvasProject(); + const saved = await saveCanvasProject({ requireRemote: false }); if (!saved) { nodesRef.current = previousNodes; connectionsRef.current = previousConnections; @@ -499,7 +507,7 @@ function InfiniteCanvasPage() { connectionsRef.current = nextConnections; setNodes(nextNodes); setConnections(nextConnections); - const saved = await saveCanvasProject(); + const saved = await saveCanvasProject({ requireRemote: false }); if (!saved) { nodesRef.current = previousNodes; connectionsRef.current = previousConnections; @@ -1628,6 +1636,7 @@ function InfiniteCanvasPage() { }, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]); useCanvasKeyboard({ + enabled: projectLoaded && !versions.preview, nodesRef, selectedNodeIdsRef, selectedConnectionId, @@ -2341,13 +2350,17 @@ function InfiniteCanvasPage() { 跳转到画布主内容
- {!focusMode && shortDramaEnabled && currentProject?.projectId ? ( + {!focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? ( openProjectAssets()} /> ) : null} -
+
+
{!focusMode ? ( } + versionsOpen={versions.open} + onToggleVersions={() => { closeAgent(); setVersionCompareRootId(null); versions.toggle(); }} title={currentProject?.title || "未命名画布"} titleDraft={titleDraft} isTitleEditing={titleEditing} @@ -2418,6 +2431,7 @@ function InfiniteCanvasPage() {
} + versionsOpen={versions.open} + onToggleVersions={() => { closeAgent(); versions.toggle(); }} dockRevealed={focusDockRevealed} zoomPercent={viewport.k} onToggleDock={() => setFocusDockRevealed((value) => !value)} @@ -2593,10 +2610,12 @@ function InfiniteCanvasPage() { ) : null}
+
{ if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; } focusCanvasNode(nodeId); }} /> +
{angleNode?.metadata?.content ? ( @@ -3104,7 +3123,10 @@ function InfiniteCanvasPage() { onInsertFolder={projectAssetScope === "canvas" ? handleProjectFolderInsert : undefined} />
+ {versions.preview ? : null} +
+
); diff --git a/web/src/pages/canvas/use-canvas-keyboard.ts b/web/src/pages/canvas/use-canvas-keyboard.ts index 42c20efdd..e5625c844 100644 --- a/web/src/pages/canvas/use-canvas-keyboard.ts +++ b/web/src/pages/canvas/use-canvas-keyboard.ts @@ -3,6 +3,7 @@ import { useEffect, type Dispatch, type SetStateAction } from "react"; import type { CanvasNodeData, ContextMenuState } from "@/types/canvas"; type UseCanvasKeyboardOptions = { + enabled?: boolean; nodesRef: { current: CanvasNodeData[] }; selectedNodeIdsRef: { current: Set }; selectedConnectionId: string | null; @@ -49,6 +50,7 @@ export function hasCanvasTextSelection(selection: TextSelectionLike | null | und } export function useCanvasKeyboard({ + enabled = true, nodesRef, selectedNodeIdsRef, selectedConnectionId, @@ -84,6 +86,7 @@ export function useCanvasKeyboard({ beginBatchConnection, }: UseCanvasKeyboardOptions) { useEffect(() => { + if (!enabled) return; const handleKeyDown = (event: KeyboardEvent) => { const target = event.target instanceof Element ? event.target : null; if (target?.closest(".canvas-node-toolbar, .canvas-node-toolbar-menu")) return; @@ -219,5 +222,5 @@ export function useCanvasKeyboard({ window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("paste", handlePaste, true); }; - }, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, onOpenSearch, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]); + }, [enabled, beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, onOpenSearch, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]); } diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts index 485dbcdc8..5e8607ddd 100644 --- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts +++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts @@ -1,5 +1,5 @@ import { mergeAgentCanvasEditor } from "@/lib/canvas/agent-canvas-patch"; -import { startTransition, useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; +import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; import { App } from "antd"; import { useNavigate } from "react-router"; @@ -9,9 +9,11 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps"; import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation"; import { listAddedSkills, type Skill } from "@/services/api/skills"; -import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync"; +import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, loadCanvasProjectForEditing, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync"; import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store"; +import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; +import { readCanvasSyncDrafts } from "@/services/canvas-sync-drafts"; import { useUserStore } from "@/stores/use-user-store"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas"; import type { CanvasHistorySnapshot } from "./use-canvas-history"; @@ -29,6 +31,8 @@ type UseCanvasProjectLifecycleOptions = { viewport: ViewportTransform; nodesRef: MutableRefObject; connectionsRef: MutableRefObject; + chatSessionsRef: MutableRefObject; + activeChatIdRef: MutableRefObject; viewportRef: MutableRefObject; historyPausedRef: MutableRefObject; setNodes: Dispatch>; @@ -58,6 +62,8 @@ export function useCanvasProjectLifecycle({ viewport, nodesRef, connectionsRef, + chatSessionsRef, + activeChatIdRef, viewportRef, historyPausedRef, setNodes, @@ -87,11 +93,20 @@ export function useCanvasProjectLifecycle({ const [agentCreatedNodes, setAgentCreatedNodes] = useState<{ projectId: string; nodes: CanvasNodeData[] } | null>(null); const viewportSaveTimerRef = useRef | null>(null); + const observedContentRef = useRef(null); + const loadLatestRef = useRef(false); + const historyRestoreRef = useRef<{ snapshotId: string; revision: number; resolve: () => void; reject: (error: unknown) => void } | null>(null); + useEffect(() => { if (!hydrated || !sessionHydrated) return; let cancelled = false; setProjectLoaded(false); setLoadError(""); + const latest = loadLatestRef.current; + loadLatestRef.current = false; + const historyRestore = historyRestoreRef.current; + historyRestoreRef.current = null; + observedContentRef.current = null; const applyRestoredProject = (targetProject: CanvasProject) => { if (cancelled) return; const fallbackTheme = useCanvasThemeStore.getState().theme; @@ -111,6 +126,9 @@ export function useCanvasProjectLifecycle({ backgroundMode: targetProject.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE, showImageInfo: targetProject.showImageInfo || false, }; + observedContentRef.current = snapshot; + chatSessionsRef.current = snapshot.chatSessions; + activeChatIdRef.current = snapshot.activeChatId; nodesRef.current = snapshot.nodes; connectionsRef.current = snapshot.connections; viewportRef.current = targetProject.viewport; @@ -129,33 +147,40 @@ export function useCanvasProjectLifecycle({ const load = async () => { const cachedProject = useCanvasStore.getState().projects.find((p) => p.id === projectId); - if (cachedProject && cachedProject.nodes?.length) { + if (!latest && !historyRestore && cachedProject && cachedProject.nodes?.length) { // 本地已有该画布的持久化缓存:先以本地数据秒开渲染,彻底消除白屏与等待 applyRestoredProject(cachedProject); } - const loadedProject = await loadCanvasProjectForEditing(projectId); + const loadedProject = await loadCanvasProjectForEditing(projectId, { latest, historyRestore: historyRestore || undefined, onLoad: applyRestoredProject }); + historyRestore?.resolve(); if (cancelled) return; if (!loadedProject) { if (!cachedProject) navigate("/canvas", { replace: true }); return; } const project = useCanvasStore.getState().projects.find((p) => p.id === projectId) || loadedProject; - applyRestoredProject(project); // 画布媒体由节点自己的视口观察器按需加载;打开时遍历并解析全部节点会让大画布形成 N+1 资源读取。 void hydrateAssistantImages(project.chatSessions || []) .then((hydratedSessions) => { - if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions)); + if (!cancelled) setChatSessions((current) => { + const merged = mergeHydratedSessions(current, hydratedSessions); + if (observedContentRef.current?.chatSessions === current) observedContentRef.current = { ...observedContentRef.current, chatSessions: merged }; + return merged; + }); }) .catch(() => { if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开"); }); }; void load().catch((error) => { + historyRestore?.reject(error); + if (!cancelled && useSyncProgressStore.getState().syncingProjects[projectId]?.phase !== "conflict") useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" }); if (!cancelled) setLoadError(error instanceof Error ? error.message : "读取画布失败,请重试"); }); return () => { cancelled = true; + historyRestore?.reject(new Error("画布页面已关闭,请重新打开查看恢复结果")); }; }, [hydrated, sessionHydrated, loadAttempt, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]); @@ -179,6 +204,13 @@ export function useCanvasProjectLifecycle({ // Merge only server-changed fields so dragging/editing other nodes can // continue while Agent media tasks complete. Same-field conflicts fail. const merged = previous ? mergeAgentCanvasEditor(previous, project, nodesRef.current, connectionsRef.current) : project; + if (observedContentRef.current) { + const observed = observedContentRef.current; + // Advance only the observed server fields; edits in live refs still + // differ from this baseline and must be persisted by the effect below. + const baseline = previous ? mergeAgentCanvasEditor(previous, project, observed.nodes, observed.connections) : project; + observedContentRef.current = { ...observed, nodes: baseline.nodes, connections: baseline.connections }; + } nodesRef.current = merged.nodes; connectionsRef.current = merged.connections; setNodes(merged.nodes); @@ -190,6 +222,9 @@ export function useCanvasProjectLifecycle({ useEffect(() => { if (!projectLoaded || historyPausedRef.current) return; + const snapshot = { nodes, connections, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo }; + if (!observedContentRef.current || JSON.stringify(observedContentRef.current) === JSON.stringify(snapshot)) return; + observedContentRef.current = snapshot; const patch = { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo }; const stored = useCanvasStore.getState().projects.find((project) => project.id === projectId); // 远端结果投影到编辑器不是一次本地编辑,避免改写时间戳并触发反向保存。 @@ -223,8 +258,11 @@ export function useCanvasProjectLifecycle({ }, [message, navigate]); const deleteCurrentProject = useCallback(async () => { - const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []); + let drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []); try { + const drafts = await readCanvasSyncDrafts(projectId); + const preserved = new Set(drafts.flatMap((draft) => draft.project.nodes.flatMap((node) => node.metadata?.drawingId ? [node.metadata.drawingId] : []))); + drawingIds = drawingIds.filter((id) => !preserved.has(id)); await deleteCanvasProjectsWithRemoteSync([projectId]); } catch (error) { message.error(error instanceof Error ? `删除画布失败:${error.message}` : "删除画布失败,请稍后重试"); @@ -242,8 +280,9 @@ export function useCanvasProjectLifecycle({ renameProject(projectId, title); }, [projectId, renameProject]); - const persistCanvasSnapshot = useCallback(async (): Promise => { - try { + const persistLocalEdits = useCallback(async () => { + const snapshot = { nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo }; + if (observedContentRef.current && JSON.stringify(observedContentRef.current) !== JSON.stringify(snapshot)) { updateProject(projectId, { nodes: nodesRef.current, connections: connectionsRef.current, @@ -253,38 +292,58 @@ export function useCanvasProjectLifecycle({ backgroundMode, showImageInfo, viewport: viewportRef.current, - directorScenes: currentProject?.directorScenes || [], }); - await flushCanvasStorePersistence(); - return true; + observedContentRef.current = snapshot; + } + updateProject(projectId, { viewport: viewportRef.current }); + await flushCanvasStorePersistence(); + }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, nodesRef, projectId, showImageInfo, updateProject, viewportRef]); + + const reloadLatestCanvasProject = useCallback(async () => { + await persistLocalEdits(); + loadLatestRef.current = true; + setLoadAttempt((value) => value + 1); + }, [persistLocalEdits]); + + const restoreCanvasProjectVersion = useCallback(async (snapshotId: string, revision: number) => { + await persistLocalEdits(); + return new Promise((resolve, reject) => { + historyRestoreRef.current?.reject(new Error("已有新的恢复请求")); + historyRestoreRef.current = { snapshotId, revision, resolve, reject }; + setLoadAttempt((value) => value + 1); + }); + }, [persistLocalEdits]); + + const saveCanvasProject = useCallback(async (options: { requireRemote?: boolean } = {}): Promise => { + try { + await persistLocalEdits(); } catch { message.error("画布保存失败,请稍后重试"); return false; } - }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]); - - const saveCanvasProject = useCallback(async (): Promise => { - if (!(await persistCanvasSnapshot())) return false; try { - await saveRemoteUserDataNow(); - message.success("画布布局和位置已保存"); + await saveRemoteUserDataNow(projectId); + message.success("画布已保存到云端"); } catch (error) { - // 本地 IndexedDB 已写入;云端失败只排队重试,不能把导入方的画布状态回滚。 - message.warning(localSavedRemotePendingMessage("本地画布布局已保存", error)); + const detail = error instanceof Error ? error.message : "未知错误"; + message.warning(`本地画布布局已保存,云端同步失败:${detail}`); + // Imports can retain their durable local result; sharing requires cloud success. + return options.requireRemote === false; } return true; - }, [message, persistCanvasSnapshot]); + }, [message, persistLocalEdits, projectId]); const forceSaveCanvasProject = useCallback(async (): Promise => { - if (!(await persistCanvasSnapshot())) return false; + try { await persistLocalEdits(); } catch { message.error("本地保存失败,请重试"); return false; } try { const result = await forceOverwriteRemoteCanvasSync(); - message.success(result.reboundNodes > 0 ? `已用本地内容覆盖云端,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "已用本地内容覆盖云端画布"); + message.success(result.reboundNodes > 0 ? `已保存,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "素材关联已核对,画布已保存"); } catch (error) { - message.error(`强制覆盖保存失败:${error instanceof Error ? error.message : "未知错误"}`); + message.error(`修复并保存失败:${error instanceof Error ? error.message : "未知错误"}`); + return false; } return true; - }, [message, persistCanvasSnapshot]); + }, [message, persistLocalEdits]); const clearCanvasFiles = useCallback(() => { cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] }); @@ -300,6 +359,8 @@ export function useCanvasProjectLifecycle({ currentProject, deleteCurrentProject, renameCurrentProject, + reloadLatestCanvasProject, + restoreCanvasProjectVersion, saveCanvasProject, forceSaveCanvasProject, updateProject, diff --git a/web/src/services/agent-canvas-sync.ts b/web/src/services/agent-canvas-sync.ts index 6ce5d7ff5..40c12898c 100644 --- a/web/src/services/agent-canvas-sync.ts +++ b/web/src/services/agent-canvas-sync.ts @@ -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(); diff --git a/web/src/services/api/user-data.ts b/web/src/services/api/user-data.ts index c39a7c45a..7c4b155e9 100644 --- a/web/src/services/api/user-data.ts +++ b/web/src/services/api/user-data.ts @@ -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 & { +export type CanvasLibrarySummary = Pick & { 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 }); +} diff --git a/web/src/services/canvas-sync-drafts.ts b/web/src/services/canvas-sync-drafts.ts new file mode 100644 index 000000000..352242248 --- /dev/null +++ b/web/src/services/canvas-sync-drafts.ts @@ -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 { + 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; + }), + ); +} diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index d617abda4..3cf94d1cf 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -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 { const epoch = sessionEpoch; @@ -472,7 +602,7 @@ export async function forceOverwriteRemoteCanvasSync(): Promise { - 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, 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, 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, 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"] }); } diff --git a/web/src/stores/canvas/use-canvas-store.ts b/web/src/stores/canvas/use-canvas-store.ts index dcb7afd43..dddd4c422 100644 --- a/web/src/stores/canvas/use-canvas-store.ts +++ b/web/src/stores/canvas/use-canvas-store.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; import { nanoid } from "nanoid"; +import { sameCanvasContent } from "@/lib/canvas/canvas-content"; import { DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, readCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; import { parseCanvasStorageDocument, rebaseCanvasProjects, serializeCanvasStorageDocument, type CanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision"; import { localForageStorageForScope } from "@/lib/localforage-storage"; @@ -14,6 +15,8 @@ import type { TimelineProject } from "@/types/timeline"; export type CanvasProject = { id: string; + revision?: number; + remoteContentHash?: string; projectId?: string; title: string; createdAt: string; @@ -448,6 +451,7 @@ export const useCanvasStore = create()( const appearanceDefault = readCanvasAppearanceDefault(); const project: CanvasProject = { id, + revision: 0, projectId, title, createdAt: now, @@ -469,6 +473,7 @@ export const useCanvasStore = create()( const now = new Date().toISOString(); const project: CanvasProject = { id: nanoid(), + revision: 0, projectId: source.projectId, title: source.title || "导入画布", createdAt: source.createdAt || now, @@ -490,20 +495,27 @@ export const useCanvasStore = create()( openProject: (id) => { return get().projects.find((item) => item.id === id) || null; }, - renameProject: (id, title) => - set((state) => ({ - projects: state.projects.map((project) => (project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project)), - })), + renameProject: (id, title) => set((state) => { + const current = state.projects.find((project) => project.id === id); + const nextTitle = title.trim() || current?.title; + if (!current || current.title === nextTitle) return state; + return { projects: state.projects.map((project) => project === current ? { ...project, title: nextTitle!, updatedAt: new Date().toISOString() } : project) }; + }), deleteProjects: (ids) => set((state) => { const projects = state.projects.filter((project) => !ids.includes(project.id)); return { projects }; }), replaceProjects: (projects) => set({ projects }), - updateProject: (id, patch) => - set((state) => ({ - projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)), - })), + updateProject: (id, patch) => set((state) => { + const current = state.projects.find((project) => project.id === id); + if (!current) return state; + const next = { ...current, ...patch }; + const contentChanged = !sameCanvasContent(current, next); + if (!contentChanged && samePersistenceValue(current.viewport, next.viewport)) return state; + if (contentChanged) next.updatedAt = new Date().toISOString(); + return { projects: state.projects.map((project) => project === current ? next : project) }; + }), }), { name: CANVAS_STORE_KEY, diff --git a/web/src/stores/use-asset-store.ts b/web/src/stores/use-asset-store.ts index 46236ab85..9bcf5a85f 100644 --- a/web/src/stores/use-asset-store.ts +++ b/web/src/stores/use-asset-store.ts @@ -13,6 +13,7 @@ import { cleanupUnusedImages, collectImageStorageKeys, resolveImageUrl, uploadIm import { cleanupUnusedMedia, collectMediaStorageKeys, resolveMediaUrl } from "@/services/file-storage"; import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGenerationArtifactCommitLock, withGenerationAssetStorageLock } from "@/services/generation-asset-repository"; import { CANVAS_STORE_KEY, commitPendingCanvasStorePersistenceLocked, pendingCanvasStorePersistence, withCanvasStorePersistenceLock } from "@/stores/canvas/use-canvas-store"; +import { readAllCanvasSyncDrafts } from "@/services/canvas-sync-drafts"; export type AssetKind = "text" | "image" | "video" | "audio" | "model" | "entity"; export type { AssetCategory } from "@/lib/asset-category"; @@ -414,6 +415,7 @@ export const useAssetStore = create()( await new Promise((resolve, reject) => { window.setTimeout(() => withGenerationArtifactCommitLock(scope, async () => { + const syncDrafts = await readAllCanvasSyncDrafts(scope); // 固定锁序:artifact -> Canvas(释放)-> Asset,避免跨 store 锁重入。 const canvasProjects = await withCanvasStorePersistenceLock(scope, async () => { await commitPendingCanvasStorePersistenceLocked(scope); @@ -423,7 +425,7 @@ export const useAssetStore = create()( await withGenerationAssetStorageLock(scope, async () => { await commitPendingAssetStorePersistenceLocked(scope); const durableAssets = (await readPersistedAssetDocumentForScope(scope)).state.assets; - const references = { projects: canvasProjects, assets: durableAssets }; + const references = { projects: canvasProjects, assets: durableAssets, syncDrafts }; const imageKeys = new Set([...frozenExtraImageKeys, ...collectImageStorageKeys(references)]); const mediaKeys = new Set([...frozenExtraMediaKeys, ...collectMediaStorageKeys(references)]); await cleanupUnusedImages( diff --git a/web/src/stores/use-sync-progress-store.ts b/web/src/stores/use-sync-progress-store.ts index f4e366b7a..9c336cc57 100644 --- a/web/src/stores/use-sync-progress-store.ts +++ b/web/src/stores/use-sync-progress-store.ts @@ -4,8 +4,9 @@ export type SyncProjectProgress = { projectId: string; total: number; completed: number; - phase: "uploading" | "saving" | "done" | "error"; + phase: "pending" | "uploading" | "saving" | "done" | "error" | "conflict"; message?: string; + draftCount?: number; }; type SyncProgressStore = { @@ -55,7 +56,7 @@ export const useSyncProgressStore = create((set, get) => ({ clearAll: () => set({ syncingProjects: {} }), isAnySyncing: () => { const list = Object.values(get().syncingProjects); - return list.some((item) => item.phase === "uploading" || item.phase === "saving"); + return list.some((item) => item.phase !== "done"); }, })); diff --git a/web/test/agent-canvas-patch.test.ts b/web/test/agent-canvas-patch.test.ts index 860875d3b..50121b076 100644 --- a/web/test/agent-canvas-patch.test.ts +++ b/web/test/agent-canvas-patch.test.ts @@ -67,3 +67,13 @@ test("task progress ahead of the server checkpoint still accepts terminal state" expect(result.nodes[0].metadata?.taskStatus).toBe("failed"); expect(isCanvasNodeGenerating(result.nodes[0])).toBe(false); }); + +test("full Agent refresh removes unchanged nodes but preserves conflicting local edits", () => { + const remaining = { ...node, id: "remaining", metadata: {} }; + const previous = { ...project, nodes: [node, remaining] }; + const incoming = { ...previous, nodes: [remaining] }; + expect(mergeAgentCanvasEditor(previous, incoming, previous.nodes, []).nodes).toEqual([remaining]); + const local = { ...node, title: "Unsaved local title" }; + expect(() => mergeAgentCanvasEditor(previous, incoming, [local, remaining], [])).toThrow("冲突"); + expect(local.title).toBe("Unsaved local title"); +}); diff --git a/web/test/agent-canvas-refresh.test.ts b/web/test/agent-canvas-refresh.test.ts index 90c32653c..fa64acf4c 100644 --- a/web/test/agent-canvas-refresh.test.ts +++ b/web/test/agent-canvas-refresh.test.ts @@ -1,16 +1,42 @@ -import { afterEach, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import localforage from "localforage"; +import { canvasContentHash } from "@/lib/canvas/canvas-content"; +import { readCanvasSyncDrafts } from "@/services/canvas-sync-drafts"; +import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; import { http } from "@/services/api/request"; -import { applyAgentCanvasPatches, initializeRemoteUserDataSession, refreshCanvasAfterAgent, resetRemoteUserDataSync, subscribeAgentCanvasRefresh } from "@/services/user-data-sync"; -import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; +import { applyAgentCanvasPatches, initializeRemoteUserDataSession, refreshCanvasAfterAgent, resetRemoteUserDataSync, subscribeAgentCanvasRefresh, saveRemoteUserDataNow } from "@/services/user-data-sync"; +import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; import { CanvasNodeType } from "@/types/canvas"; -const initial: CanvasProject = { id: "agent-test", title: "测试", createdAt: "2026-01-01", updatedAt: "2026-01-01", nodes: [], connections: [], chatSessions: [], activeChatId: null, viewport: { x: 0, y: 0, k: 1 } }; -const remote: CanvasProject = { ...initial, nodes: [{ id: "text-1", type: CanvasNodeType.Text, title: "剧本", position: { x: 20, y: 20 }, width: 320, height: 240, metadata: { content: "正文" } }] }; +const initial: CanvasProject = { id: "agent-test", revision: 1, title: "测试", createdAt: "2026-01-01", updatedAt: "2026-01-01", nodes: [], connections: [], chatSessions: [], activeChatId: null, viewport: { x: 0, y: 0, k: 1 } }; +const remote: CanvasProject = { ...initial, revision: 2, nodes: [{ id: "text-1", type: CanvasNodeType.Text, title: "剧本", position: { x: 20, y: 20 }, width: 320, height: 240, metadata: { content: "正文" } }] }; let unsubscribe = () => {}; let restore = () => {}; -afterEach(() => { unsubscribe(); restore(); resetRemoteUserDataSync(); }); +const originalGet = localforage.getItem; +const originalSet = localforage.setItem; +const originalWindow = globalThis.window; +const indexed = new Map(); +beforeEach(() => { + resetRemoteUserDataSync(); + Object.defineProperty(globalThis, "window", { configurable: true, value: { + setTimeout: () => 1, clearTimeout: () => {}, + localStorage: { getItem: () => "agent-user", setItem: () => {} }, + } }); + indexed.clear(); + localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem; + localforage.setItem = (async (key: string, value: unknown) => { indexed.set(key, value); return value; }) as typeof localforage.setItem; +}); +afterEach(async () => { + unsubscribe(); restore(); resetRemoteUserDataSync(); + await flushCanvasStorePersistence(); + localforage.getItem = originalGet; + localforage.setItem = originalSet; + if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window; + else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); +}); async function setup(project: CanvasProject = remote) { + initial.remoteContentHash = await canvasContentHash(initial); useCanvasStore.setState({ projects: [initial] }); await initializeRemoteUserDataSession("agent-user"); const mock = spyOn(http, "get").mockResolvedValue({ project }); @@ -37,7 +63,7 @@ test("unsaved editor conflicts preserve store and acknowledged baseline", async expect(useCanvasStore.getState().projects[0]).toEqual(initial); unsubscribe(); await refreshCanvasAfterAgent(initial.id); - expect(useCanvasStore.getState().projects[0]).toEqual(remote); + expect(useCanvasStore.getState().projects[0]).toMatchObject(remote); }); test("dirty persisted edits are not replaced or delivered to the editor", async () => { @@ -80,7 +106,7 @@ test("media admission and completion refresh the editor with references and resu await refreshCanvasAfterAgent(initial.id); expect(editor.connections).toEqual(project.connections); expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.referenceNodeIds).toEqual(["image-1"]); - const complete: CanvasProject = { ...project, nodes: project.nodes.map((node) => node.id === "video-1" ? { ...node, metadata: { ...node.metadata, status: "success", storageKey: "resource:video-result" } } : node) }; + const complete: CanvasProject = { ...project, revision: 3, nodes: project.nodes.map((node) => node.id === "video-1" ? { ...node, metadata: { ...node.metadata, status: "success", storageKey: "resource:video-result" } } : node) }; mock.mockResolvedValue({ project: complete }); await refreshCanvasAfterAgent(initial.id); expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.storageKey).toBe("resource:video-result"); @@ -92,7 +118,7 @@ test("incremental batches update the editor once without full canvas requests", const get = await setup(); let deliveries = 0; unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; }); - const patch = { canvasId: initial.id, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }; + const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }; await applyAgentCanvasPatches(initial.id, [patch, patch]); expect(get).not.toHaveBeenCalled(); expect(deliveries).toBe(1); @@ -101,18 +127,18 @@ test("incremental batches update the editor once without full canvas requests", test("already projected deltas still advance the remote baseline", async () => { await setup(); - useCanvasStore.setState({ projects: [remote] }); + useCanvasStore.setState({ projects: [{ ...remote, revision: 1 }] }); let deliveries = 0; unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; }); - await applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }]); + await applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }]); expect(deliveries).toBe(0); - await expect(refreshCanvasAfterAgent(initial.id)).resolves.toEqual(remote); + await expect(refreshCanvasAfterAgent(initial.id)).resolves.toMatchObject(remote); expect(deliveries).toBe(0); }); test("editor rejection keeps delta projection atomic and retryable", async () => { await setup(); - const patch = { canvasId: initial.id, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }; + const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }; unsubscribe = subscribeAgentCanvasRefresh(() => { throw new Error("编辑冲突"); }); await expect(applyAgentCanvasPatches(initial.id, [patch])).rejects.toThrow("编辑冲突"); expect(useCanvasStore.getState().projects[0]).toEqual(initial); @@ -127,10 +153,11 @@ test("50 distinct media completions project once and keep the local viewport", a const nodes = Array.from({ length: 50 }, (_, index) => ({ id: `video-${index}`, title: `动画${index}`, type: CanvasNodeType.Video, position: { x: index * 400, y: 100 }, width: 320, height: 180, metadata: { status: "loading" as const, taskId: `task-${index}`, taskStatus: "running" } })); const pending = { ...initial, nodes }; useCanvasStore.setState({ projects: [pending] }); + pending.remoteContentHash = await canvasContentHash(pending); await initializeRemoteUserDataSession("agent-user"); let deliveries = 0; unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; }); - await applyAgentCanvasPatches(initial.id, nodes.map((before, index) => ({ canvasId: initial.id, updatedAt: initial.updatedAt, nodes: [{ before, after: { ...before, metadata: { ...before.metadata, status: index % 2 ? "error" as const : "success" as const, taskStatus: index % 2 ? "failed" : "succeeded" } } }], connections: [] }))); + await applyAgentCanvasPatches(initial.id, nodes.map((before, index) => ({ canvasId: initial.id, baseRevision: index + 1, revision: index + 2, updatedAt: initial.updatedAt, nodes: [{ before, after: { ...before, metadata: { ...before.metadata, status: index % 2 ? "error" as const : "success" as const, taskStatus: index % 2 ? "failed" : "succeeded" } } }], connections: [] }))); const result = useCanvasStore.getState().projects[0]; expect(result.nodes.filter((node) => node.metadata?.status === "success")).toHaveLength(25); expect(result.nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(25); @@ -138,3 +165,35 @@ test("50 distinct media completions project once and keep the local viewport", a expect(deliveries).toBe(1); expect(get).not.toHaveBeenCalled(); }); + +test("a missing Agent revision preserves the local draft and prevents automatic overwrite", async () => { + await setup(); + await expect(applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 2, revision: 3, nodes: [], connections: [] }])).rejects.toThrow("版本不连续"); + expect(useCanvasStore.getState().projects[0].revision).toBe(1); + expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("conflict"); + expect((await readCanvasSyncDrafts(initial.id))[0].project.revision).toBe(1); + await expect(saveRemoteUserDataNow(initial.id)).rejects.toThrow("云端画布已有更新"); +}); + +test("Agent updates merge a local title edit and the next save uses the new revision", async () => { + await setup(); + useCanvasStore.setState({ projects: [{ ...initial, title: "本地标题" }] }); + const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }; + await applyAgentCanvasPatches(initial.id, [patch]); + const merged = useCanvasStore.getState().projects[0]; + expect(merged.title).toBe("本地标题"); + expect(merged.revision).toBe(2); + expect(merged.nodes).toEqual(remote.nodes); + const put = spyOn(http, "put").mockImplementation(async (_url, body) => { + const project = (body as { project: CanvasProject }).project; + expect(project.revision).toBe(2); + return { project: { ...project, revision: 3 } } as never; + }); + try { + await saveRemoteUserDataNow(initial.id); + expect(put).toHaveBeenCalledTimes(1); + await applyAgentCanvasPatches(initial.id, [patch]); + expect(useCanvasStore.getState().projects[0].revision).toBe(3); + expect(useCanvasStore.getState().projects[0].title).toBe("本地标题"); + } finally { put.mockRestore(); } +}); diff --git a/web/test/agent-canvas-sync.test.ts b/web/test/agent-canvas-sync.test.ts index 9f92d0b69..5e291c90d 100644 --- a/web/test/agent-canvas-sync.test.ts +++ b/web/test/agent-canvas-sync.test.ts @@ -71,3 +71,14 @@ test("fallback snapshots obey the configured minimum refresh interval", async () expect(times).toHaveLength(2); expect(times[1] - times[0]).toBeGreaterThanOrEqual(100); }); + +test("a full invalidation and undo still refresh after delta support was detected", async () => { + const refresh = mock(async () => {}); + const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches: async () => {}, refresh, onError: () => {}, batchMs: 0, refreshIntervalMs: 0 }); + sync.receive(delta); await sleep(); + sync.receive(event("canvas_updated", { canvasId: "canvas", requiresRefresh: true })); await sleep(); + expect(refresh).toHaveBeenCalledTimes(1); + sync.receive(event("canvas_undone", { canvasId: "canvas" })); await sleep(); + expect(refresh).toHaveBeenCalledTimes(2); + sync.dispose(); +}); diff --git a/web/test/canvas-remote-revision.test.ts b/web/test/canvas-remote-revision.test.ts new file mode 100644 index 000000000..852339823 --- /dev/null +++ b/web/test/canvas-remote-revision.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import localforage from "localforage"; +import { apiClient } from "../src/services/api/request"; +import { canvasContentHash } from "../src/lib/canvas/canvas-content"; +import { rebaseCanvasProjects, parseCanvasStorageDocument } from "../src/lib/canvas/canvas-storage-revision"; +import { readCanvasSyncDrafts } from "../src/services/canvas-sync-drafts"; +import { initializeRemoteUserDataSession, installRemoteUserDataAutoSync, loadCanvasProjectForEditing, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData } from "../src/services/user-data-sync"; +import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "../src/stores/canvas/use-canvas-store"; +import { flushAssetStorePersistence, useAssetStore } from "../src/stores/use-asset-store"; +import { useSyncProgressStore } from "../src/stores/use-sync-progress-store"; +import { CanvasNodeType } from "../src/types/canvas"; + +const originalWindow = globalThis.window; +const originalAdapter = apiClient.defaults.adapter; +const originalGet = localforage.getItem; +const originalSet = localforage.setItem; +const indexed = new Map(); +let scope = ""; +let sequence = 0; +let autoSave: (() => void) | undefined; +let remote = new Map(); +let requests: Array<{ method: string; id: string; project?: CanvasProject }> = []; +let beforePut: (() => Promise) | undefined; + +function canvas(id = "canvas"): CanvasProject { + return { + id, + revision: 1, + title: id, + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-01T00:00:00Z", + nodes: [{ id: "old-image", type: CanvasNodeType.Image, title: "image", position: { x: 0, y: 0 }, width: 100, height: 100 }], + connections: [], + chatSessions: [], + activeChatId: null, + backgroundMode: "dots", + showImageInfo: false, + viewport: { x: 0, y: 0, k: 1 }, + directorScenes: [], + }; +} + +function addNode(project: CanvasProject, id: string) { + return { ...project, nodes: [...project.nodes, { ...project.nodes[0], id, type: CanvasNodeType.Video }] }; +} + +beforeEach(async () => { + resetRemoteUserDataSync(); + scope = `canvas-revision-test-${++sequence}`; + indexed.clear(); + requests = []; + autoSave = undefined; + beforePut = undefined; + remote = new Map([ + ["canvas", canvas()], + ["other", canvas("other")], + ]); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + setTimeout: (callback: () => void) => { + autoSave = callback; + return 1; + }, + clearTimeout: () => { + autoSave = undefined; + }, + localStorage: { getItem: () => scope, setItem: () => undefined }, + }, + }); + localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem; + localforage.setItem = (async (key: string, value: string) => { + indexed.set(key, value); + return value; + }) as typeof localforage.setItem; + apiClient.defaults.adapter = async (config) => { + const method = config.method || "get"; + const id = String(config.url).split("/").at(-1)!; + const body = config.data ? JSON.parse(config.data) : undefined; + requests.push({ method, id, project: body?.project }); + let data: unknown; + let status = 200; + if (id === "snapshot") data = { projects: structuredClone([...remote.values()]), assets: [] }; + else if (id === "restore" && method === "post") { + const current = remote.get("canvas")!; + if (body.revision !== current.revision) status = 409; + else { + const saved = { ...canvas(), title: "historical content", revision: current.revision! + 1 }; + remote.set("canvas", saved); + data = { project: saved }; + } + } else if (method === "put") { + await beforePut?.(); + const project = body.project as CanvasProject; + const current = remote.get(id); + if (project.revision !== (current?.revision ?? 0)) status = 409; + else { + const saved = { ...structuredClone(project), revision: project.revision! + 1 }; + remote.set(id, saved); + data = { project: saved }; + } + } else data = { project: structuredClone(remote.get(id)) }; + return { config, status, statusText: "", headers: {}, data: { code: status === 200 ? 0 : status, data, msg: status === 409 ? "版本冲突" : "ok" } }; + }; + useCanvasStore.setState({ projects: [] }); + useAssetStore.setState({ assets: [] }); + installRemoteUserDataAutoSync(); + await syncRemoteUserData(scope); +}); + +afterEach(async () => { + resetRemoteUserDataSync(); + await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]); + apiClient.defaults.adapter = originalAdapter; + localforage.getItem = originalGet; + localforage.setItem = originalSet; + if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window; + else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); +}); + +test("stale viewport, no-op restore and open do not submit old content", async () => { + const old = useCanvasStore.getState().openProject("canvas")!; + remote.set("canvas", { ...addNode(canvas(), "afternoon-video"), revision: 2 }); + useCanvasStore.getState().updateProject("canvas", { viewport: { x: 100, y: 40, k: 2 } }); + useCanvasStore.getState().updateProject("canvas", { nodes: structuredClone(old.nodes), connections: [] }); + expect(autoSave).toBeUndefined(); + expect(useCanvasStore.getState().openProject("canvas")!.updatedAt).toBe(old.updatedAt); + await flushCanvasStorePersistence(); + await saveRemoteUserDataNow(); + const latest = await loadCanvasProjectForEditing("canvas"); + expect(latest.nodes.map((node) => node.id)).toEqual(["old-image", "afternoon-video"]); + expect(latest.revision).toBe(2); + expect(latest.viewport).toEqual({ x: 100, y: 40, k: 2 }); + await saveRemoteUserDataNow(); + expect(requests.filter((request) => request.method === "put")).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 }); + useCanvasStore.getState().renameProject("other", "other edit"); + await expect(saveRemoteUserDataNow()).rejects.toThrow("版本冲突"); + expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "remote-video"]); + expect(remote.get("other")!.title).toBe("other edit"); + expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("conflict"); + expect((await readCanvasSyncDrafts("canvas"))[0].project.nodes.at(-1)!.id).toBe("local-video"); + useCanvasStore.getState().renameProject("canvas", "more local edits"); + await expect(saveRemoteUserDataNow()).rejects.toThrow("云端画布已有更新"); + useCanvasStore.getState().renameProject("other", "still saves independently"); + await saveRemoteUserDataNow("other"); + expect(remote.get("other")!.title).toBe("still saves independently"); + await expect(saveRemoteUserDataNow(["canvas", "other"])).rejects.toThrow("云端画布已有更新"); + expect(requests.filter((request) => request.method === "put" && request.id === "canvas")).toHaveLength(1); + await expect(saveRemoteUserDataNow("canvas")).rejects.toThrow("云端画布已有更新"); + await loadCanvasProjectForEditing("canvas", { latest: true }); + expect(useCanvasStore.getState().openProject("canvas")!.nodes.at(-1)!.id).toBe("remote-video"); + expect((await readCanvasSyncDrafts("canvas")).some((draft) => draft.project.title === "more local edits")).toBe(true); + resetRemoteUserDataSync(); + await useCanvasStore.persist.rehydrate(); + await syncRemoteUserData(scope); + expect(useSyncProgressStore.getState().syncingProjects.canvas.draftCount).toBe(2); +}); + +test("edits during a save retain the right ancestor revision and are saved next", async () => { + let release!: () => void; + let started!: () => void; + const waiting = new Promise((resolve) => { + release = resolve; + }); + const entered = new Promise((resolve) => { + started = resolve; + }); + beforePut = async () => { + started(); + await waiting; + }; + useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "first-video").nodes }); + const saving = saveRemoteUserDataNow(); + await entered; + useCanvasStore.getState().updateProject("canvas", { nodes: addNode(useCanvasStore.getState().openProject("canvas")!, "second-video").nodes }); + release(); + await saving; + expect(requests.filter((request) => request.method === "put").map((request) => request.project!.revision)).toEqual([1, 2]); + expect(remote.get("canvas")!.revision).toBe(3); + expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "first-video", "second-video"]); + expect(useCanvasStore.getState().openProject("canvas")!.remoteContentHash).toBe(await canvasContentHash(useCanvasStore.getState().openProject("canvas")!)); + expect(requests.filter((request) => request.method === "put").every((request) => !("viewport" in request.project!) && !("remoteContentHash" in request.project!))).toBe(true); +}); + +test("login archives unsynced changes before replacing cache and keeps user scopes separate", async () => { + useCanvasStore.getState().renameProject("canvas", "unsaved draft"); + await flushCanvasStorePersistence(); + resetRemoteUserDataSync(); + await useCanvasStore.persist.rehydrate(); + await syncRemoteUserData(scope); + expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("canvas"); + expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("unsaved draft"); + expect(await readCanvasSyncDrafts("canvas", "another-user")).toEqual([]); + expect(requests.some((request) => request.method === "put")).toBe(false); +}); + +test("failed draft storage prevents replacement by the latest server content", async () => { + const working = localforage.setItem; + useCanvasStore.getState().renameProject("canvas", "must survive"); + localforage.setItem = (async (key: string, value: unknown) => { + if (key.includes("sync-drafts")) throw new Error("disk full"); + return working(key, value); + }) as typeof localforage.setItem; + await expect(loadCanvasProjectForEditing("canvas", { latest: true })).rejects.toThrow("disk full"); + expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("must survive"); +}); + +test("local storage rebase cannot pair old edits with another tab's newer revision", () => { + const base = canvas(); + const local = addNode(base, "unsaved-local"); + const durable = { ...addNode(base, "remote-video"), revision: 2 }; + const document = parseCanvasStorageDocument(null, [durable]); + const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 0 }).document.state.projects[0]; + expect(rebased.revision).toBe(1); + expect(rebased.nodes.at(-1)!.id).toBe("unsaved-local"); +}); + +test("loading aligns live editor content before publishing its server revision", async () => { + remote.set("canvas", { ...addNode(canvas(), "new-cloud-node"), revision: 2 }); + let applied = false; + await loadCanvasProjectForEditing("canvas", { + onLoad: (project) => { + expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1); + expect(project.revision).toBe(2); + expect(project.nodes.at(-1)!.id).toBe("new-cloud-node"); + applied = true; + }, + }); + expect(applied).toBe(true); + expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2); +}); + +test("edits arriving during draft backup prevent replacement of the live canvas", async () => { + useCanvasStore.getState().renameProject("canvas", "first local edit"); + const working = localforage.setItem; + let release!: () => void; + let started!: () => void; + const waiting = new Promise((resolve) => { + release = resolve; + }); + const entered = new Promise((resolve) => { + started = resolve; + }); + localforage.setItem = (async (key: string, value: unknown) => { + if (key.includes("sync-drafts:")) { + started(); + await waiting; + } + return working(key, value); + }) as typeof localforage.setItem; + const loading = loadCanvasProjectForEditing("canvas", { latest: true }); + await entered; + useCanvasStore.getState().renameProject("canvas", "second local edit"); + release(); + await expect(loading).rejects.toThrow("画布仍在更新"); + expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("second local edit"); +}); + +test("unknown revision cannot borrow the latest revision to submit stale content", async () => { + const local = useCanvasStore.getState().openProject("canvas")!; + useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === local.id ? { ...local, revision: undefined, title: "legacy draft" } : project)) })); + await expect(saveRemoteUserDataNow()).rejects.toThrow("缺少画布版本"); + expect(requests.filter((request) => request.method === "put")).toHaveLength(0); + expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("conflict"); + expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("legacy draft"); +}); + +test("HTTP environments without Web Crypto still distinguish unsaved content", async () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto")!; + Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined }); + try { + const baseline = await canvasContentHash(canvas()); + expect(baseline).toBe(await canvasContentHash({ ...canvas(), viewport: { x: 100, y: 0, k: 2 } })); + expect(baseline).not.toBe(await canvasContentHash(addNode(canvas(), "new-video"))); + } finally { + Object.defineProperty(globalThis, "crypto", descriptor); + } +}); + +test("history restore preserves the draft and installs a new revision without an extra content PUT", async () => { + useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "unsaved-video").nodes }); + let applied = false; + const restored = await loadCanvasProjectForEditing("canvas", { + historyRestore: { snapshotId: "historical", revision: 1 }, + onLoad: (project) => { + expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1); + expect(project.revision).toBe(2); + applied = true; + }, + }); + expect(applied).toBe(true); + expect(restored.title).toBe("historical content"); + expect((await readCanvasSyncDrafts("canvas")).some((draft) => draft.project.nodes.some((node) => node.id === "unsaved-video"))).toBe(true); + await saveRemoteUserDataNow("canvas"); + expect(requests.filter((request) => request.method === "post")).toHaveLength(1); + expect(requests.filter((request) => request.method === "put")).toHaveLength(0); +}); + +test("a stale history restore preserves both current cloud content and local edits", async () => { + remote.set("canvas", { ...addNode(canvas(), "new-cloud-video"), revision: 2 }); + useCanvasStore.getState().renameProject("canvas", "local draft"); + await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("版本冲突"); + expect(remote.get("canvas")!.nodes.at(-1)!.id).toBe("new-cloud-video"); + expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("local draft"); + expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("local draft"); +}); + +test("reopened dirty cache with the same ancestor stays pending until actually saved", async () => { + useCanvasStore.getState().renameProject("canvas", "offline draft"); + await flushCanvasStorePersistence(); + await initializeRemoteUserDataSession(scope); + const loaded = await loadCanvasProjectForEditing("canvas"); + expect(loaded.title).toBe("offline draft"); + expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("pending"); + expect(remote.get("canvas")!.title).toBe("canvas"); + await saveRemoteUserDataNow("canvas"); + expect(remote.get("canvas")!.title).toBe("offline draft"); + expect(remote.get("canvas")!.revision).toBe(2); +}); + +test("failed draft backup prevents a history restore request", async () => { + const working = localforage.setItem; + localforage.setItem = (async (key: string, value: unknown) => { + if (key.includes("sync-drafts:")) throw new Error("disk full"); + return working(key, value); + }) as typeof localforage.setItem; + await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("disk full"); + expect(requests.some((request) => request.method === "post")).toBe(false); + expect(remote.get("canvas")!.revision).toBe(1); +});