diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index ec5336344..9ca0eb110 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 d3ad935cf..40865feb9 100644 --- a/backend/internal/app/cloud_agent_media.go +++ b/backend/internal/app/cloud_agent_media.go @@ -539,7 +539,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 d8dabd859..d3837796c 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 22 +const CurrentSchemaVersion int64 = 23 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -86,6 +86,9 @@ var schemaMigrations = []migration{ {version: 22, name: "banner_announcement_notice_type", checksum: "sha256:banner-announcement-notice-type-v22-20260917", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.BannerAnnouncement{}) }}, + {version: 23, name: "canvas_revision_history", checksum: "sha256:canvas-revision-history-v23-20260918", 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 4837d7d9e..21f869dec 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -307,6 +307,44 @@ func TestMigrateSchemaV22AddsBannerAnnouncementNoticeType(t *testing.T) { } } +func TestMigrateSchemaV23BackfillsCanvasRevisions(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-canvas-revisions-v23?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 = ?", 23).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 eb9550a62..f1f14a757 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -86,6 +86,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 f57827638..3ae4f43d1 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 45533a70c..4fe92e954 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 7df0a146e..7ee247630 100644 --- a/backend/internal/service/aliases_types.go +++ b/backend/internal/service/aliases_types.go @@ -85,7 +85,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 c85bfce0c..4ff03adb9 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -23,7 +23,7 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 22。版本 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 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态;版本 19 `payment_plugin_version` 为已有 `payment_provider_configs` 和 `payment_orders` 补上 `plugin_version`,用于把配置和订单钉在具体插件版本上;版本 20 `banner_announcements` 创建首页常驻滚动通知表;版本 21 `banner_announcement_title_runs` 为通知标题增加样式分段;版本 22 `banner_announcement_notice_type` 增加通知类型底色。已发布版本的实现和校验和不得修改。 +当前结构版本为 23。版本 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 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态;版本 19 `payment_plugin_version` 为已有 `payment_provider_configs` 和 `payment_orders` 补上 `plugin_version`,用于把配置和订单钉在具体插件版本上;版本 20 `banner_announcements` 创建首页常驻滚动通知表;版本 21 `banner_announcement_title_runs` 为通知标题增加样式分段;版本 22 `banner_announcement_notice_type` 增加通知类型底色;版本 23 `canvas_revision_history` 为既有画布回填 `revision = 1` 并新增历史快照与资源引用表。已发布版本的实现和校验和不得修改。 历史发布曾使用版本 6 `asset_library_folders`,之后合并引入了上述 6/7 顺序冲突。迁移器仅在该历史记录的名称和校验和均精确匹配时选择历史顺序:保留版本 6 的素材目录记录及时间,在版本 7 应用 `resource_playback_variant`,再继续版本 8。新数据库及已采用“6 播放副本、7 素材目录”的数据库保持原顺序;状态检查与启动校验采用同一套识别规则。未知名称、被改动的校验和或冲突的版本 7 记录仍会拒绝迁移,不通过删除、改名或伪造已应用记录绕过检查。 @@ -31,6 +31,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 9e4c8337c..5503229b1 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -33,6 +33,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 164dcf0af..3a698e175 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 95464a872..f38549bb1 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -42,6 +42,16 @@ description: 当前项目已实现的核心工作台能力与使用入口 浮窗支持移动、调整大小和浏览器偏好恢复;后端支持逐轮对话、可信历史、取消、Skills 快照、画布摘要/精读、审批后的画布写入及媒体生成。运行中可插话(下一步生效,不打断正在飞的调用);多步任务可用 `plan_update` 维护待办清单并在输入框上方展示,未完成项会催办;`request_approval` 下首次 ≥2 项清单会先确认,`auto` 直接建立。`ask_user` 可弹出选项并结束本轮。个人记忆由 `remember_lesson` 写入待审,用户在「设置 → Agent 记忆」自己批准、添加、导出导入,并可用自己的文本模型手动或按日/周/月压缩相近条目。系统提示只带已批准记忆的索引(标题+适用场景),完整做法由 `recall_lessons` 按 topic/关键词取;管理员可按用户查看和删除,不能代批。只读模式不能写记忆。终态旧轮次按其冻结合同保留为不可变历史,新消息使用当前合同创建新轮,不篡改父运行;旧合同下仍活动或待审批的运行不会被新版服务恢复执行。生成由预算和独立审批控制,画布写入使用内容快照哈希;未开放外部 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 6843438d2..dfe8b3905 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -17,6 +17,18 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [ ] 用 Excel/WPS 验收中文、含逗号/引号/换行名称、长数字及前导零订单号、微积分和公式触发文本。数字订单号的单引号保护前缀属于预期;不要按数值列导入。 - [ ] 检查明暗主题、窄屏筛选换行、键盘操作及导出 loading;本清单中的人工验收尚未由用户确认。 +## 画布保存保护与版本记录 + +已增加版本 CAS、冲突草稿、视口与内容分离、打开时核对云端、历史快照和恢复,以及右侧历史树和独立只读预览。上游云端 Agent 与创作任务使用同一版本和历史事务。专项测试覆盖并发旧写入、断档增量、草稿写入失败、恢复冲突、历史权限、保留数量和历史媒体删除保护。Schema 顺延为 v23,不占用已发布的 v16 Agent 记忆版本。 + +隔离 SQLite/PostgreSQL 的迁移与版本/快照测试已通过;前端构建、类型检查与 lint 通过。真实 Chrome 的组件测试(合成数据与模拟接口)通过了明暗主题、桌面预览缩放、草稿查看、窄屏抽屉、恢复确认与无内容写请求检查;完整登录态和真实生成渠道仍按下列清单验收。 + +- [ ] 双标签页编辑同一画布:旧页平移/缩放不发内容 PUT,旧页内容保存收到冲突后保留草稿,另一画布仍可正常保存。 +- [ ] 在有未同步编辑时加载最新版或恢复历史,确认本地草稿可下载,恢复后版本递增且恢复前内容仍可找到。 +- [ ] 明暗主题、桌面和窄屏下检查右侧历史树、草稿页、预览缩放和恢复确认;预览期间快捷键和拖拽不能修改当前编辑器,返回当前画布后可继续生成和编辑。 +- [ ] 使用真实受控模型验收 Agent 生成任务完成与本地编辑同时发生时的冲突提示;自动化测试不调用收费渠道。 +- [ ] 既有部署执行 schema 23 迁移并刷新前端,确认旧画布正文保留;历史只从升级后积累,不能恢复升级前已被覆盖的内容。 + ## 首页常驻滚动通知(2026-09-18) diff --git a/docs/public/images/canvas-version-history.png b/docs/public/images/canvas-version-history.png new file mode 100644 index 000000000..691cdba25 Binary files /dev/null and b/docs/public/images/canvas-version-history.png differ 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); +});