From 6ad1adc197a5de93de209bfe4f671a2da3ecf8a8 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:26:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E6=9D=A1=E4=BB=B6=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E4=B8=8E=E5=8E=86=E5=8F=B2=20-=20=E5=87=8F=E5=B0=91?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E5=90=8C=E6=AD=A5=E4=BC=A0=E8=BE=93=E5=B9=B6?= =?UTF-8?q?=E6=A0=A1=E5=87=86=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/canvas_bridge.go | 4 + backend/internal/canvas/canvas_history.go | 101 ++++++- .../internal/canvas/canvas_history_test.go | 42 +++ backend/internal/canvas/user_data.go | 4 + backend/internal/handler/user_data.go | 96 +++++- .../internal/handler/user_data_page_test.go | 69 +++++ backend/internal/model/canvas_extensions.go | 74 +++++ .../internal/repository/canvas_extensions.go | 276 ++++++++++++++++++ backend/internal/repository/canvas_history.go | 4 +- .../repository/canvas_project_read.go | 15 + docs/content/docs/backend/http-api.mdx | 4 +- web/src/lib/canvas/canvas-content.ts | 21 +- web/src/services/api/request.ts | 10 +- web/src/services/api/user-data.ts | 17 +- web/src/services/user-data-sync.ts | 26 +- web/test/canvas-conditional-get.test.ts | 31 ++ web/test/canvas-remote-revision.test.ts | 14 + 17 files changed, 783 insertions(+), 25 deletions(-) create mode 100644 backend/internal/model/canvas_extensions.go create mode 100644 backend/internal/repository/canvas_extensions.go create mode 100644 backend/internal/repository/canvas_project_read.go create mode 100644 web/test/canvas-conditional-get.test.ts diff --git a/backend/internal/app/canvas_bridge.go b/backend/internal/app/canvas_bridge.go index 6e785b948..5d1baa764 100644 --- a/backend/internal/app/canvas_bridge.go +++ b/backend/internal/app/canvas_bridge.go @@ -210,6 +210,10 @@ func (s *Service) UserCanvasProjectSummaries(userID string) ([]UserDataSummary, return s.canvasDomain().UserCanvasProjectSummaries(userID) } +func (s *Service) UserCanvasProjectMetadata(userID string, id string) (*model.CanvasProject, error) { + return s.canvasDomain().UserCanvasProjectMetadata(userID, id) +} + func (s *Service) UserCanvasProject(userID string, id string) (json.RawMessage, error) { return s.canvasDomain().UserCanvasProject(userID, id) } diff --git a/backend/internal/canvas/canvas_history.go b/backend/internal/canvas/canvas_history.go index 8b5425242..fdbf9f0a1 100644 --- a/backend/internal/canvas/canvas_history.go +++ b/backend/internal/canvas/canvas_history.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "io" "net/http" "strings" "time" @@ -169,19 +170,99 @@ func buildCanvasSnapshot(before *model.CanvasProject, after model.CanvasProject, // 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 + metadata := []struct { + key string + value any + }{ + {key: "id", value: project.ID}, + {key: "title", value: project.Title}, + {key: "projectId", value: project.ProjectID}, + {key: "revision", value: project.Revision}, + {key: "createdAt", value: project.CreatedAt}, + {key: "updatedAt", value: project.UpdatedAt}, } - 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) + replacements := make(map[string]json.RawMessage, len(metadata)) + for _, item := range metadata { + encoded, err := json.Marshal(item.value) if err != nil { return nil, err } - payload[key] = encoded + replacements[item.key] = encoded } - return json.Marshal(payload) + + decoder := json.NewDecoder(strings.NewReader(project.PayloadJSON)) + opening, err := decoder.Token() + if err != nil { + return nil, err + } + if opening != json.Delim('{') { + return nil, kernel.BadAuthRequest("画布数据格式错误") + } + + result := make([]byte, 0, len(project.PayloadJSON)+128) + result = append(result, '{') + written := make(map[string]struct{}, len(replacements)) + first := true + appendField := func(key string, value json.RawMessage) error { + if !first { + result = append(result, ',') + } + first = false + encodedKey, err := json.Marshal(key) + if err != nil { + return err + } + result = append(result, encodedKey...) + result = append(result, ':') + result = append(result, value...) + return nil + } + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return nil, err + } + key, ok := token.(string) + if !ok { + return nil, kernel.BadAuthRequest("画布数据格式错误") + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, err + } + if replacement, exists := replacements[key]; exists { + if _, duplicate := written[key]; duplicate { + continue + } + value = replacement + written[key] = struct{}{} + } + if err := appendField(key, value); err != nil { + return nil, err + } + } + closing, err := decoder.Token() + if err != nil { + return nil, err + } + if closing != json.Delim('}') { + return nil, kernel.BadAuthRequest("画布数据格式错误") + } + var trailing json.RawMessage + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return nil, kernel.BadAuthRequest("画布数据格式错误") + } + return nil, err + } + for _, item := range metadata { + if _, exists := written[item.key]; exists { + continue + } + if err := appendField(item.key, replacements[item.key]); err != nil { + return nil, err + } + } + result = append(result, '}') + return json.RawMessage(result), nil } diff --git a/backend/internal/canvas/canvas_history_test.go b/backend/internal/canvas/canvas_history_test.go index 7157a2a59..aeb4b79e6 100644 --- a/backend/internal/canvas/canvas_history_test.go +++ b/backend/internal/canvas/canvas_history_test.go @@ -4,13 +4,55 @@ import ( "encoding/json" "errors" "net/http" + "reflect" "strings" "testing" + "time" "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" ) +func TestCanvasProjectPayloadReplacesMetadataAndPreservesDocument(t *testing.T) { + project := model.CanvasProject{ + ID: "canvas-authoritative", + Title: "Current title", + ProjectID: "project-current", + Revision: 9, + CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + UpdatedAt: time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC), + PayloadJSON: `{"id":"stale","title":"stale","revision":1,"nodes":[ { "id": "node-1", "metadata": {"content":"large"} } ],"connections":[],"custom":{"preserve":true}}`, + } + raw, err := canvasProjectPayload(project) + if err != nil { + t.Fatal(err) + } + var actual map[string]json.RawMessage + if err := json.Unmarshal(raw, &actual); err != nil { + t.Fatal(err) + } + for key, want := range map[string]any{ + "id": project.ID, "title": project.Title, "projectId": project.ProjectID, + "revision": project.Revision, "createdAt": project.CreatedAt, "updatedAt": project.UpdatedAt, + } { + var got any + if err := json.Unmarshal(actual[key], &got); err != nil { + t.Fatal(err) + } + encodedWant, _ := json.Marshal(want) + var decodedWant any + _ = json.Unmarshal(encodedWant, &decodedWant) + if !reflect.DeepEqual(got, decodedWant) { + t.Errorf("%s = %#v, want %#v", key, got, decodedWant) + } + } + for _, key := range []string{"nodes", "connections", "custom"} { + if len(actual[key]) == 0 || !json.Valid(actual[key]) { + t.Errorf("original %s payload was not preserved: %s", key, actual[key]) + } + } +} + func TestCanvasHistoryRestore(t *testing.T) { svc := newCanvasHistoryTestService(t) actor := &model.User{ID: "owner"} diff --git a/backend/internal/canvas/user_data.go b/backend/internal/canvas/user_data.go index 7e1e2b4d4..34ce5f024 100644 --- a/backend/internal/canvas/user_data.go +++ b/backend/internal/canvas/user_data.go @@ -218,6 +218,10 @@ func (s *Service) UserCanvasProjectSummaries(userID string) ([]UserDataSummary, return result, nil } +func (s *Service) UserCanvasProjectMetadata(userID string, id string) (*model.CanvasProject, error) { + return s.repo.CanvasProjectMetadataForUser(userID, id) +} + func (s *Service) UserCanvasProject(userID string, id string) (json.RawMessage, error) { project, err := s.repo.CanvasProjectForUser(userID, id) if err != nil { diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index 3524b1fe4..3948eec11 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -1,6 +1,8 @@ package handler import ( + "bytes" + "compress/gzip" "encoding/json" "errors" "fmt" @@ -535,12 +537,25 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } + metadata, err := svc.UserCanvasProjectMetadata(user.ID, c.Param("id")) + if err != nil { + fail(c, http.StatusNotFound, err) + return + } + etag := canvasProjectResponseETag(metadata) + c.Header("ETag", etag) + c.Header("Cache-Control", "private, no-cache") + addVaryHeader(c, "Accept-Encoding") + if ifNoneMatch(c.GetHeader("If-None-Match"), etag) { + c.Status(http.StatusNotModified) + return + } project, err := svc.UserCanvasProject(user.ID, c.Param("id")) if err != nil { fail(c, http.StatusNotFound, err) return } - ok(c, gin.H{"project": project}) + okCanvasProject(c, project) }) r.GET("/canvas-projects/:id/history", func(c *gin.Context) { user, err := currentUser(c, svc) @@ -673,6 +688,75 @@ func hasUserAssetPageFilters(c *gin.Context) bool { return false } +func okCanvasProject(c *gin.Context, project json.RawMessage) { + payload, err := json.Marshal(gin.H{ + "code": service.CodeOK, + "data": gin.H{"project": project}, + "msg": "ok", + }) + if err != nil { + failInternal(c, http.StatusInternalServerError, err) + return + } + if len(payload) >= 1024 && acceptsGzip(c.GetHeader("Accept-Encoding")) { + var compressed bytes.Buffer + writer, err := gzip.NewWriterLevel(&compressed, gzip.BestSpeed) + if err != nil { + failInternal(c, http.StatusInternalServerError, err) + return + } + _, writeErr := writer.Write(payload) + closeErr := writer.Close() + if writeErr != nil || closeErr != nil { + failInternal(c, http.StatusInternalServerError, errors.Join(writeErr, closeErr)) + return + } + c.Header("Content-Encoding", "gzip") + c.Header("Content-Length", strconv.Itoa(compressed.Len())) + c.Data(http.StatusOK, "application/json; charset=utf-8", compressed.Bytes()) + return + } + c.Data(http.StatusOK, "application/json; charset=utf-8", payload) +} + +func acceptsGzip(header string) bool { + wildcardAccepted := false + for _, item := range strings.Split(header, ",") { + parts := strings.Split(item, ";") + encoding := strings.TrimSpace(strings.ToLower(parts[0])) + accepted := true + for _, parameter := range parts[1:] { + name, value, found := strings.Cut(strings.TrimSpace(parameter), "=") + if found && strings.EqualFold(strings.TrimSpace(name), "q") { + quality, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + accepted = err == nil && quality > 0 + } + } + if encoding == "gzip" { + return accepted + } + if encoding == "*" { + wildcardAccepted = accepted + } + } + return wildcardAccepted +} + +func addVaryHeader(c *gin.Context, value string) { + for _, existing := range c.Writer.Header().Values("Vary") { + for _, token := range strings.Split(existing, ",") { + if strings.EqualFold(strings.TrimSpace(token), value) { + return + } + } + } + c.Header("Vary", strings.Join(append(c.Writer.Header().Values("Vary"), value), ", ")) +} + +func canvasProjectResponseETag(project *model.CanvasProject) string { + return `W/` + strconv.Quote(fmt.Sprintf("canvas-%d", project.Revision)) +} + func resourceResponseETag(resource *model.Resource) string { value := strings.Trim(strings.TrimSpace(resource.ETag), `"`) if value == "" { @@ -682,8 +766,16 @@ func resourceResponseETag(resource *model.Resource) string { } func ifNoneMatch(header string, etag string) bool { + normalize := func(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && strings.EqualFold(value[:2], "W/") { + return strings.TrimSpace(value[2:]) + } + return value + } + etag = normalize(etag) for _, candidate := range strings.Split(header, ",") { - candidate = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(candidate), "W/")) + candidate = normalize(candidate) if candidate == "*" || candidate == etag { return true } diff --git a/backend/internal/handler/user_data_page_test.go b/backend/internal/handler/user_data_page_test.go index c04a6bc1c..02f78f76d 100644 --- a/backend/internal/handler/user_data_page_test.go +++ b/backend/internal/handler/user_data_page_test.go @@ -1,8 +1,16 @@ package handler import ( + "bytes" + "compress/gzip" + "encoding/json" + "io" + "net/http/httptest" + "strings" "testing" + "time" + "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/service" "github.com/gin-gonic/gin" @@ -30,3 +38,64 @@ func TestUserDataIncrementalRoutesAreRegistered(t *testing.T) { } } } + +func TestCanvasProjectResponseETagChangesWithRevisionMetadata(t *testing.T) { + project := &model.CanvasProject{Revision: 7, UpdatedAt: time.Date(2026, 9, 28, 12, 0, 0, 123000000, time.UTC)} + first := canvasProjectResponseETag(project) + if first != `W/"canvas-7"` { + t.Fatalf("unexpected canvas ETag: %s", first) + } + if !ifNoneMatch(`"canvas-7"`, first) || !ifNoneMatch(first, first) { + t.Fatal("strong and weak validators should match for conditional GET") + } + project.Revision++ + if first == canvasProjectResponseETag(project) { + t.Fatal("revision change must invalidate the canvas ETag") + } +} + +func TestAcceptsGzipHonorsQualityAndWildcard(t *testing.T) { + for _, test := range []struct { + header string + want bool + }{ + {header: "gzip, br", want: true}, + {header: "gzip;q=0, *;q=1", want: false}, + {header: "br, *;q=0.5", want: true}, + {header: "br, gzip;q=0.2", want: true}, + {header: "br, gzip;q=0", want: false}, + } { + if got := acceptsGzip(test.header); got != test.want { + t.Errorf("acceptsGzip(%q) = %v, want %v", test.header, got, test.want) + } + } +} + +func TestOkCanvasProjectCompressesLargeResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("GET", "/api/canvas-projects/canvas", nil) + ctx.Request.Header.Set("Accept-Encoding", "gzip") + project := json.RawMessage(`{"nodes":["` + strings.Repeat("x", 2048) + `"]}`) + + okCanvasProject(ctx, project) + + if got := recorder.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("Content-Encoding = %q, want gzip", got) + } + reader, err := gzip.NewReader(bytes.NewReader(recorder.Body.Bytes())) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if err := reader.Close(); err != nil { + t.Fatal(err) + } + if !json.Valid(body) { + t.Fatalf("decompressed response is not valid JSON: %s", body) + } +} diff --git a/backend/internal/model/canvas_extensions.go b/backend/internal/model/canvas_extensions.go new file mode 100644 index 000000000..2151aeb12 --- /dev/null +++ b/backend/internal/model/canvas_extensions.go @@ -0,0 +1,74 @@ +package model + +import ( + "time" +) + +// Canvas 数据模型扩展 + +// Canvas 画布 +type Canvas struct { + ID string `json:"id" gorm:"primaryKey"` + UserID string `json:"userId" gorm:"index"` + Name string `json:"name"` + Description string `json:"description"` + Metadata map[string]interface{} `json:"metadata" gorm:"type:jsonb"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// CanvasNode 画布节点 +type CanvasNode struct { + ID string `json:"id" gorm:"primaryKey"` + CanvasID string `json:"canvasId" gorm:"index"` + Type string `json:"type"` + Content string `json:"content" gorm:"type:text"` + Position *CanvasPosition `json:"position" gorm:"type:jsonb"` + Size *CanvasSize `json:"size" gorm:"type:jsonb"` + ParentID *string `json:"parentId" gorm:"index"` + Children []string `json:"children" gorm:"type:jsonb"` + Metadata map[string]interface{} `json:"metadata" gorm:"type:jsonb"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// CanvasPosition 节点位置 +type CanvasPosition struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// CanvasSize 节点尺寸 +type CanvasSize struct { + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +// CanvasNodeUpdate 节点更新请求 +type CanvasNodeUpdate struct { + Content *string `json:"content,omitempty"` + Position *CanvasPosition `json:"position,omitempty"` + Size *CanvasSize `json:"size,omitempty"` + ParentID *string `json:"parentId,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// CanvasRelationship 节点关系 +type CanvasRelationship struct { + From string `json:"from"` + To string `json:"to"` + Type string `json:"type"` // parent, reference, dependency, flow +} + +// Approval 审批请求 +type Approval struct { + ID string `json:"id" gorm:"primaryKey"` + UserID string `json:"userId" gorm:"index"` + RunID string `json:"runId" gorm:"index"` + ToolName string `json:"toolName"` + Data map[string]any `json:"data" gorm:"type:jsonb"` + Status string `json:"status"` // pending, approved, rejected + Decision string `json:"decision"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/repository/canvas_extensions.go b/backend/internal/repository/canvas_extensions.go new file mode 100644 index 000000000..322315a7f --- /dev/null +++ b/backend/internal/repository/canvas_extensions.go @@ -0,0 +1,276 @@ +package repository + +import ( + "errors" + "fmt" + "time" + + "infinite-canvas/backend/internal/model" +) + +// Canvas 数据访问层扩展 + +// Canvas 获取画布 +func (r *Repository) Canvas(userID, canvasID string) (model.Canvas, error) { + var canvas model.Canvas + err := r.db.Where("user_id = ? AND id = ?", userID, canvasID).First(&canvas).Error + if err != nil { + return canvas, fmt.Errorf("get canvas: %w", err) + } + return canvas, nil +} + +// GetCanvas is the explicit repository name used by Agent policy builders. +func (r *Repository) GetCanvas(userID, canvasID string) (model.Canvas, error) { + return r.Canvas(userID, canvasID) +} + +func (r *Repository) CanvasNodes(userID, canvasID string) ([]model.CanvasNode, error) { + var nodes []model.CanvasNode + err := r.db.Where("canvas_id IN (SELECT id FROM canvases WHERE user_id = ? AND id = ?)", userID, canvasID). + Find(&nodes).Error + if err != nil { + return nil, fmt.Errorf("get canvas nodes: %w", err) + } + return nodes, nil +} + +// CanvasNode 获取单个节点 +func (r *Repository) CanvasNode(userID, canvasID, nodeID string) (model.CanvasNode, error) { + var node model.CanvasNode + err := r.db.Where("id = ? AND canvas_id IN (SELECT id FROM canvases WHERE user_id = ? AND id = ?)", + nodeID, userID, canvasID).First(&node).Error + if err != nil { + return node, fmt.Errorf("get canvas node: %w", err) + } + return node, nil +} + +// CreateCanvasNode 创建节点 +func (r *Repository) CreateCanvasNode(userID, canvasID string, node model.CanvasNode) (model.CanvasNode, error) { + // 验证画布存在且属于用户 + var canvas model.Canvas + err := r.db.Where("user_id = ? AND id = ?", userID, canvasID).First(&canvas).Error + if err != nil { + return node, fmt.Errorf("canvas not found: %w", err) + } + + // 生成节点 ID + if node.ID == "" { + node.ID = generateNodeID() + } + node.CanvasID = canvasID + + // 创建节点 + err = r.db.Create(&node).Error + if err != nil { + return node, fmt.Errorf("create canvas node: %w", err) + } + + return node, nil +} + +// UpdateCanvasNode 更新节点 +func (r *Repository) UpdateCanvasNode(userID, canvasID, nodeID string, update model.CanvasNodeUpdate) (model.CanvasNode, error) { + // 获取现有节点 + node, err := r.CanvasNode(userID, canvasID, nodeID) + if err != nil { + return node, err + } + + // 应用更新 + if update.Content != nil { + node.Content = *update.Content + } + if update.Position != nil { + node.Position = update.Position + } + if update.Size != nil { + node.Size = update.Size + } + if update.ParentID != nil { + node.ParentID = update.ParentID + } + if update.Metadata != nil { + if node.Metadata == nil { + node.Metadata = make(map[string]interface{}) + } + for k, v := range update.Metadata { + node.Metadata[k] = v + } + } + + // 保存 + err = r.db.Save(&node).Error + if err != nil { + return node, fmt.Errorf("update canvas node: %w", err) + } + + return node, nil +} + +// DeleteCanvasNode 删除节点,可选递归删除子节点。 +func (r *Repository) DeleteCanvasNode(userID, canvasID, nodeID string, recursive ...bool) error { + // 验证节点存在且属于用户 + _, err := r.CanvasNode(userID, canvasID, nodeID) + if err != nil { + return err + } + if len(recursive) > 0 && recursive[0] { + var children []model.CanvasNode + if err := r.db.Where("canvas_id = ? AND parent_id = ?", canvasID, nodeID).Find(&children).Error; err != nil { + return fmt.Errorf("list child nodes: %w", err) + } + for _, child := range children { + if err := r.DeleteCanvasNode(userID, canvasID, child.ID, true); err != nil { + return err + } + } + } + + // 删除节点 + if err := r.db.Where("id = ? AND canvas_id = ?", nodeID, canvasID).Delete(&model.CanvasNode{}).Error; err != nil { + return fmt.Errorf("delete canvas node: %w", err) + } + return nil +} + +// GetCanvasNodeChildren 返回指定父节点的直接子节点。 +func (r *Repository) GetCanvasNodeChildren(userID, canvasID, parentID string) ([]model.CanvasNode, error) { + if _, err := r.CanvasNode(userID, canvasID, parentID); err != nil { + return nil, err + } + var children []model.CanvasNode + if err := r.db.Where("canvas_id = ? AND parent_id = ?", canvasID, parentID).Find(&children).Error; err != nil { + return nil, fmt.Errorf("get canvas node children: %w", err) + } + return children, nil +} + +// CanvasNodes 获取画布所有节点 +func (r *Repository) CreateCanvasRelationship(userID, canvasID string, rel model.CanvasRelationship) error { + // 验证节点存在 + _, err := r.CanvasNode(userID, canvasID, rel.From) + if err != nil { + return fmt.Errorf("source node not found: %w", err) + } + _, err = r.CanvasNode(userID, canvasID, rel.To) + if err != nil { + return fmt.Errorf("target node not found: %w", err) + } + + // 存储关系到 from 节点的 metadata + fromNode, err := r.CanvasNode(userID, canvasID, rel.From) + if err != nil { + return err + } + + if fromNode.Metadata == nil { + fromNode.Metadata = make(map[string]interface{}) + } + + relationshipsKey := "relationships" + relationships, ok := fromNode.Metadata[relationshipsKey].([]interface{}) + if !ok { + relationships = []interface{}{} + } + + // 检查关系是否已存在 + for _, r := range relationships { + if relMap, ok := r.(map[string]interface{}); ok { + if relMap["to"] == rel.To && relMap["type"] == rel.Type { + return nil // 关系已存在 + } + } + } + + // 添加新关系 + relationships = append(relationships, map[string]interface{}{ + "to": rel.To, + "type": rel.Type, + }) + fromNode.Metadata[relationshipsKey] = relationships + + // 保存 + err = r.db.Save(&fromNode).Error + if err != nil { + return fmt.Errorf("save relationship: %w", err) + } + + return nil +} + +// DeleteCanvasRelationship 删除节点关系 +func (r *Repository) DeleteCanvasRelationship(userID, canvasID, from, to, relType string) error { + fromNode, err := r.CanvasNode(userID, canvasID, from) + if err != nil { + return err + } + + if fromNode.Metadata == nil { + return errors.New("relationship not found") + } + + relationshipsKey := "relationships" + relationships, ok := fromNode.Metadata[relationshipsKey].([]interface{}) + if !ok { + return errors.New("relationship not found") + } + + // 过滤掉要删除的关系 + filtered := make([]interface{}, 0) + found := false + for _, r := range relationships { + if relMap, ok := r.(map[string]interface{}); ok { + if relMap["to"] == to && relMap["type"] == relType { + found = true + continue + } + } + filtered = append(filtered, r) + } + + if !found { + return errors.New("relationship not found") + } + + fromNode.Metadata[relationshipsKey] = filtered + + // 保存 + err = r.db.Save(&fromNode).Error + if err != nil { + return fmt.Errorf("delete relationship: %w", err) + } + + return nil +} + +// CreateApproval 创建审批 +func (r *Repository) CreateApproval(approval *model.Approval) error { + return r.db.Create(approval).Error +} + +// GetApproval 获取审批 +func (r *Repository) GetApproval(approvalID string) (*model.Approval, error) { + var approval model.Approval + err := r.db.Where("id = ?", approvalID).First(&approval).Error + if err != nil { + return nil, err + } + return &approval, nil +} + +// UpdateApprovalDecision 更新审批决策 +func (r *Repository) UpdateApprovalDecision(approvalID, decision string) error { + return r.db.Model(&model.Approval{}). + Where("id = ?", approvalID). + Updates(map[string]interface{}{ + "status": "completed", + "decision": decision, + }).Error +} + +// generateNodeID 生成节点 ID(简化实现) +func generateNodeID() string { + return fmt.Sprintf("node_%d", time.Now().UnixNano()) +} diff --git a/backend/internal/repository/canvas_history.go b/backend/internal/repository/canvas_history.go index 7e4f6e87c..7ff3f887c 100644 --- a/backend/internal/repository/canvas_history.go +++ b/backend/internal/repository/canvas_history.go @@ -13,8 +13,8 @@ import ( "gorm.io/gorm/clause" ) -var ErrCanvasHistoryResourceMissing = errors.New("canvas history resource missing") -var ErrCanvasHistoryResourceReferenced = errors.New("resource referenced by canvas history") +var ErrCanvasHistoryResourceMissing = errors.New("画布历史引用的资源不存在") +var ErrCanvasHistoryResourceReferenced = errors.New("资源仍被画布历史引用,无法删除") type CanvasHistoryResourceMissingError struct { ResourceIDs []string diff --git a/backend/internal/repository/canvas_project_read.go b/backend/internal/repository/canvas_project_read.go new file mode 100644 index 000000000..b58895def --- /dev/null +++ b/backend/internal/repository/canvas_project_read.go @@ -0,0 +1,15 @@ +package repository + +import "infinite-canvas/backend/internal/model" + +// CanvasProjectMetadataForUser reads only fields needed to validate a cached +// canvas representation, avoiding the large JSON body when the caller's ETag +// is still current. +func (r *Repository) CanvasProjectMetadataForUser(userID string, id string) (*model.CanvasProject, error) { + var project model.CanvasProject + if err := r.db.Select("id", "user_id", "project_id", "title", "revision", "created_at", "updated_at"). + First(&project, "id = ? AND user_id = ?", id, userID).Error; err != nil { + return nil, err + } + return &project, nil +} diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 6d1fbe1ef..fb82b814d 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -167,7 +167,7 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考 | 接口 | 行为 | | --- | --- | -| `GET /canvas-projects/:id` | 返回当前内容及权威版本号 | +| `GET /canvas-projects/:id` | 返回当前内容及权威版本号;响应包含基于数据库 `revision` 的弱 `ETag` 和 `Cache-Control: private, no-cache`。请求可用 `If-None-Match` 做条件读取,版本未变化时返回无正文的 `304`。 | | `GET /canvas-projects/:id/history` | 返回 `snapshots` 摘要与 `currentRevision`,不批量读取历史正文 | | `GET /canvas-projects/:id/history/:snapshotId` | 按需返回 `snapshot` 摘要和 `project` 历史正文 | | `POST /canvas-projects/:id/history/:snapshotId/restore` | 请求 `{ revision }` 为当前画布版本,成功返回新版本摘要;恢复前强制备份当前内容 | @@ -176,6 +176,8 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考 平移和缩放只持久化浏览器视口,内容 PUT 忽略客户端 `viewport` 和 `remoteContentHash`。前端遇到 409/428 会保留账号隔离的本地草稿、暂停该画布自动提交,并提供加载最新版和下载草稿入口;载入最新版前草稿写入失败时停止替换。其他画布仍可保存。 +单画布读取会先查询归属用户的版本元数据,再判断 `If-None-Match`;命中时不会读取大型画布正文。未命中时返回完整项目 JSON。正文达到 1 KiB 且客户端声明接受 gzip 时响应使用 gzip 压缩,并带 `Vary: Accept-Encoding`;因此客户端应以 HTTP 状态码区分 `200` 正文和 `304` 未变化,不要把 `304` 当作业务错误。 + 云端 Agent 与创作任务的写入共用版本和历史事务;Agent `canvasPatch` 携带 `baseRevision`、`revision`,客户端只接受连续增量,跳过重复事件,缺口或同字段冲突时保留草稿并重新核对。删除、重排或顶层内容变化无法由增量完整表示时,事件携带 `requiresRefresh: true` 且不附 `canvasPatch`,客户端完整核对后才推进基线;撤销事件同样触发核对。保存日志仅记录请求/链路 ID、画布/用户 ID、版本、节点数量变化及 HTTP 状态,不记录正文、提示词或媒体 URL。 ## 欢迎页开放配置 diff --git a/web/src/lib/canvas/canvas-content.ts b/web/src/lib/canvas/canvas-content.ts index 852d032ba..08ad34d9f 100644 --- a/web/src/lib/canvas/canvas-content.ts +++ b/web/src/lib/canvas/canvas-content.ts @@ -12,11 +12,30 @@ export function sameCanvasContent(left: CanvasProject | undefined, right: Canvas if (!left || !right) return false; const a = canvasContentSnapshot(left) as Record; const b = canvasContentSnapshot(right) as Record; - return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((key) => a[key] === b[key] || canonicalize(a[key]) === canonicalize(b[key])); + return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((key) => { + if (a[key] === b[key]) return true; + if ((key === "nodes" || key === "connections") && Array.isArray(a[key]) && Array.isArray(b[key])) { + const leftItems = a[key] as unknown[]; + const rightItems = b[key] as unknown[]; + return leftItems.length === rightItems.length && leftItems.every((item, index) => item === rightItems[index] || canonicalValue(item) === canonicalValue(rightItems[index])); + } + return canonicalValue(a[key]) === canonicalValue(b[key]); + }); } type ContentSnapshot = ReturnType; const contentHashes = new WeakMap }>(); +// Canvas content trees are immutable, so unchanged node objects can reuse their serialized comparison value. +const canonicalValueCache = new WeakMap(); + +function canonicalValue(value: unknown) { + if (!value || typeof value !== "object") return canonicalize(value); + const cached = canonicalValueCache.get(value); + if (cached) return cached; + const serialized = canonicalize(value); + canonicalValueCache.set(value, serialized); + return serialized; +} // Store updates are immutable. Reuse a hash across viewport/revision-only copies; // any content field/reference change invalidates it. Weak keys bound its lifetime. diff --git a/web/src/services/api/request.ts b/web/src/services/api/request.ts index 8406db719..2699ef8b3 100644 --- a/web/src/services/api/request.ts +++ b/web/src/services/api/request.ts @@ -46,9 +46,10 @@ export const apiClient = axios.create({ baseURL: apiBaseURL, withCredentials: tr * 取消请求必须继续抛出 AbortError,不能被包装成普通失败,否则页面切换会被误报为错误。 */ -export async function request(promise: Promise<{ data: BackendEnvelope; status?: number; headers?: unknown }>) { +export async function request(promise: Promise<{ data: BackendEnvelope; status?: number; headers?: unknown }>, options: { allowNotModified?: boolean } = {}) { try { const response = await promise; + if (response.status === 304 && options.allowNotModified) return { notModified: true } as T; if (response.data.code !== 0) { throw new ApiError(response.data.msg || "请求失败", { status: response.status, @@ -122,10 +123,13 @@ function retryAfterMilliseconds(headers: unknown) { return Math.max(0, retryAt - Date.now()); } -export type HttpRequestConfig = Omit; +export type HttpRequestConfig = Omit & { + allowNotModified?: boolean; +}; async function send(method: string, url: string, data?: unknown, config?: HttpRequestConfig) { - return request(apiClient.request>({ method, url, data, ...config })); + const { allowNotModified, ...axiosConfig } = config || {}; + return request(apiClient.request>({ method, url, data, ...axiosConfig }), { allowNotModified }); } /** diff --git a/web/src/services/api/user-data.ts b/web/src/services/api/user-data.ts index d304f3a64..e89abb119 100644 --- a/web/src/services/api/user-data.ts +++ b/web/src/services/api/user-data.ts @@ -1,7 +1,6 @@ import type { Asset } from "@/stores/use-asset-store"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; -import { http, compactApiParams } from "@/services/api/request"; - +import { compactApiParams, http } from "@/services/api/request"; export type RemoteUserDataSummary = { id: string; @@ -117,8 +116,18 @@ export function listRemoteCanvasProjects() { return http.get<{ projects: RemoteUserDataSummary[] }>("/canvas-projects"); } -export function getRemoteCanvasProject(id: string) { - return http.get<{ project: CanvasProject }>(`/canvas-projects/${encodeURIComponent(id)}`); +export function getRemoteCanvasProject(id: string, knownProject?: CanvasProject) { + if (!knownProject || knownProject.id !== id || !Number.isSafeInteger(knownProject.revision)) { + return http.get<{ project: CanvasProject }>(`/canvas-projects/${encodeURIComponent(id)}`).then((result) => ({ ...result, notModified: false })); + } + const etag = `"canvas-${knownProject.revision}"`; + return http + .get<{ project: CanvasProject } | { notModified: true }>(`/canvas-projects/${encodeURIComponent(id)}`, { + headers: { "If-None-Match": etag }, + validateStatus: (status) => (status >= 200 && status < 300) || status === 304, + allowNotModified: true, + }) + .then((result) => ("notModified" in result ? { project: knownProject, notModified: true } : { ...result, notModified: false })); } export function upsertRemoteCanvasProject(project: CanvasProject, options?: { repairMissingResources?: boolean }) { diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index b385f19a7..583795b84 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -89,7 +89,8 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest? } let remote: CanvasProject; try { - remote = (await getRemoteCanvasProject(id)).project; + const knownRemote = verifiedProjects.has(id) ? acknowledgedProjects.get(id) : undefined; + remote = (await getRemoteCanvasProject(id, knownRemote)).project; } catch (error) { if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布"); const local = useCanvasStore.getState().openProject(id); @@ -158,7 +159,8 @@ export async function refreshCanvasAfterAgent(id: string) { const epoch = sessionEpoch; return withRemoteUserDataSyncExclusive(async () => { if (!activeRemoteUserId) throw new Error("请先登录再刷新 Agent 画布结果"); - const { project } = await getRemoteCanvasProject(id); + const knownRemote = verifiedProjects.has(id) ? acknowledgedProjects.get(id) : undefined; + const { project } = await getRemoteCanvasProject(id, knownRemote); if (epoch !== sessionEpoch) throw new Error("账号已切换"); const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id); const baseline = acknowledgedProjects.get(id); @@ -823,6 +825,26 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { if (pending) syncQueued = true; } catch (error) { const conflict = error instanceof ApiError && error.reason !== "canvas_history_resources_missing" && (error.status === 409 || error.status === 428); + if (conflict) { + try { + const { project: remote } = await getRemoteCanvasProject(source.id); + const current = useCanvasStore.getState().openProject(source.id); + if (current && current === useCanvasStore.getState().openProject(source.id) && sameCanvasContent(current, remote)) { + const hash = await canvasContentHash(remote); + if (current === useCanvasStore.getState().openProject(source.id)) { + const reconciled = { ...remote, viewport: current.viewport, remoteContentHash: hash }; + acknowledgedProjects.set(source.id, reconciled); + verifiedProjects.add(source.id); + useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project.id === source.id ? reconciled : project) })); + await flushCanvasStorePersistence(); + useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "done", message: "云端内容一致,已自动校准版本" }); + continue; + } + } + } catch { + // Keep the original conflict and preserve the local draft below. + } + } useSyncProgressStore.getState().setProjectProgress(source.id, { phase: conflict ? "conflict" : "error", message: error instanceof Error ? error.message : "云端同步失败,等待重试", diff --git a/web/test/canvas-conditional-get.test.ts b/web/test/canvas-conditional-get.test.ts new file mode 100644 index 000000000..c50bf163a --- /dev/null +++ b/web/test/canvas-conditional-get.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { getRemoteCanvasProject } from "../src/services/api/user-data"; +import { apiClient } from "../src/services/api/request"; +import type { CanvasProject } from "../src/stores/canvas/use-canvas-store"; + +const originalAdapter = apiClient.defaults.adapter; + +afterEach(() => { + apiClient.defaults.adapter = originalAdapter; +}); + +describe("conditional canvas reads", () => { + test("reuses a verified project when the server returns 304", async () => { + const known = { + id: "canvas-1", + title: "Cached canvas", + revision: 4, + updatedAt: "2026-09-28T12:00:00.000Z", + } as CanvasProject; + let requestEtag = ""; + apiClient.defaults.adapter = async (config) => { + const headers = config.headers as { get?: (name: string) => string | undefined; [key: string]: unknown } | undefined; + requestEtag = String(headers?.get?.("If-None-Match") ?? headers?.["If-None-Match"] ?? ""); + return { config, status: 304, statusText: "Not Modified", headers: {}, data: null }; + }; + + await expect(getRemoteCanvasProject(known.id, known)).resolves.toEqual({ project: known, notModified: true }); + expect(requestEtag).toBe('"canvas-4"'); + }); +}); diff --git a/web/test/canvas-remote-revision.test.ts b/web/test/canvas-remote-revision.test.ts index 1b214d8fa..d43d928df 100644 --- a/web/test/canvas-remote-revision.test.ts +++ b/web/test/canvas-remote-revision.test.ts @@ -232,6 +232,20 @@ test("load latest, save and reload stay synced when Agent history replays an unv expect(requests.filter((request) => request.method === "put")).toHaveLength(1); }); +test("a stale save auto-reconciles when the cloud already contains the identical local content", async () => { + useCanvasStore.getState().renameProject("canvas", "same content saved elsewhere"); + beforePut = async () => { + const current = useCanvasStore.getState().openProject("canvas")!; + remote.set("canvas", { ...structuredClone(current), revision: current.revision! + 1 }); + }; + + await expect(saveRemoteUserDataNow("canvas")).resolves.toBeUndefined(); + + expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ title: "same content saved elsewhere", revision: 2 }); + expect(useSyncProgressStore.getState().syncingProjects.canvas).toMatchObject({ phase: "done", message: "云端内容一致,已自动校准版本" }); + expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0); +}); + test("a conflict preserves drafts, stops retries and does not block another canvas", async () => { remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 }); useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes });