feat(canvas): 条件读取与历史 - 减少画布同步传输并校准版本
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
74
backend/internal/model/canvas_extensions.go
Normal file
74
backend/internal/model/canvas_extensions.go
Normal 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"`
|
||||
}
|
||||
276
backend/internal/repository/canvas_extensions.go
Normal file
276
backend/internal/repository/canvas_extensions.go
Normal 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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
15
backend/internal/repository/canvas_project_read.go
Normal file
15
backend/internal/repository/canvas_project_read.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user