fix(canvas): 画布版本 - 合并 PR #528 防止旧内容覆盖并支持历史恢复

将画布 revision CAS 与历史快照接到 schema v23,保留当前顶栏积分弹窗和已有菜单入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCatandCursor committed 2026-09-18 10:40:23 +08:00
commit aae7b15ccc
69 files changed
+3571 -293

No files matched your search

+1 -1
View File
@@ -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
+38 -11
View File
@@ -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")
+1 -1
View File
@@ -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{
+1 -1
View File
@@ -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, "冲突")
+1 -1
View File
@@ -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)}
+1 -1
View File
@@ -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{},
+11
View File
@@ -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)
+1 -1
View File
@@ -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
+185
View File
@@ -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, &notFound) || 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, &notFound) || 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, &currentRevision); !errors.As(err, &notFound) || 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, &current)
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, &current); 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)
}
+87 -51
View File
@@ -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
+2 -1
View File
@@ -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
}
+4 -1
View File
@@ -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 {
+2
View File
@@ -86,6 +86,8 @@ func Models() []any {
&model.WorkflowStepTask{},
&model.ProductionTaskLink{},
&model.CanvasProject{},
&model.CanvasSnapshot{},
&model.CanvasSnapshotResource{},
&model.CanvasShare{},
&model.PromptTemplate{},
&model.UserPromptCustomization{},
+106
View File
@@ -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]
+71
View File
@@ -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"`
}
+1
View File
@@ -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(&copy, 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(&copy)
}(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) })
}
+1 -11
View File
@@ -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.
+49 -25
View File
@@ -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
+1 -1
View File
@@ -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