fix(canvas): 画布版本 - 合并 PR #528 防止旧内容覆盖并支持历史恢复
将画布 revision CAS 与历史快照接到 schema v23,保留当前顶栏积分弹窗和已有菜单入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
aae7b15ccc
69 files changed
+3571
-293
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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("画布已发生后续变化,未执行撤销")
|
||||
}
|
||||
|
||||
@@ -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, "冲突")
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -86,6 +86,8 @@ func Models() []any {
|
||||
&model.WorkflowStepTask{},
|
||||
&model.ProductionTaskLink{},
|
||||
&model.CanvasProject{},
|
||||
&model.CanvasSnapshot{},
|
||||
&model.CanvasSnapshotResource{},
|
||||
&model.CanvasShare{},
|
||||
&model.PromptTemplate{},
|
||||
&model.UserPromptCustomization{},
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user