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
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
## 欢迎页开放配置
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 : "云端同步失败,等待重试",
|
||||
|
||||
31
web/test/canvas-conditional-get.test.ts
Normal file
31
web/test/canvas-conditional-get.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user