feat(canvas): 条件读取与历史 - 减少画布同步传输并校准版本

This commit is contained in:
ddcat666
2026-09-29 08:26:29 +08:00
parent aaa0a63cda
commit aa100c4532
17 changed files with 783 additions and 25 deletions

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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"}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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"`
}

View File

@@ -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())
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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。
## 欢迎页开放配置

View File

@@ -12,11 +12,30 @@ export function sameCanvasContent(left: CanvasProject | undefined, right: Canvas
if (!left || !right) return false;
const a = canvasContentSnapshot(left) as Record<string, unknown>;
const b = canvasContentSnapshot(right) as Record<string, unknown>;
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<typeof canvasContentSnapshot>;
const contentHashes = new WeakMap<CanvasProject["nodes"], { content: ContentSnapshot; hash: Promise<string> }>();
// Canvas content trees are immutable, so unchanged node objects can reuse their serialized comparison value.
const canonicalValueCache = new WeakMap<object, string>();
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.

View File

@@ -46,9 +46,10 @@ export const apiClient = axios.create({ baseURL: apiBaseURL, withCredentials: tr
* 取消请求必须继续抛出 AbortError,不能被包装成普通失败,否则页面切换会被误报为错误。
*/
export async function request<T>(promise: Promise<{ data: BackendEnvelope<T>; status?: number; headers?: unknown }>) {
export async function request<T>(promise: Promise<{ data: BackendEnvelope<T>; 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<AxiosRequestConfig, "method" | "url" | "data" | "baseURL">;
export type HttpRequestConfig = Omit<AxiosRequestConfig, "method" | "url" | "data" | "baseURL"> & {
allowNotModified?: boolean;
};
async function send<T>(method: string, url: string, data?: unknown, config?: HttpRequestConfig) {
return request<T>(apiClient.request<BackendEnvelope<T>>({ method, url, data, ...config }));
const { allowNotModified, ...axiosConfig } = config || {};
return request<T>(apiClient.request<BackendEnvelope<T>>({ method, url, data, ...axiosConfig }), { allowNotModified });
}
/**

View File

@@ -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 }) {

View File

@@ -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<string, string>, 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 : "云端同步失败,等待重试",

View File

@@ -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"');
});
});

View File

@@ -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 });