feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力
This commit is contained in:
@@ -40,7 +40,7 @@ func (s *Service) RepairUserCanvasProject(userID string, raw json.RawMessage) (U
|
||||
}
|
||||
|
||||
func canvasRevisionConflict() error {
|
||||
return kernel.NewAppError(http.StatusConflict, "云端画布已有更新,已停止覆盖;请保留本地草稿并加载最新版本")
|
||||
return kernel.NewAppError(http.StatusConflict, "云端画布已有更新,未确认操作会保留并按原请求自动重放")
|
||||
}
|
||||
|
||||
type CanvasHistoryList struct {
|
||||
|
||||
@@ -14,6 +14,7 @@ type Host interface {
|
||||
PrepareResourceDelivery(userID string, resource *model.Resource, options assets.AccessOptions, rangeHeader string) (*assets.ResourceDelivery, error)
|
||||
WithStorageLock(fn func() error) error
|
||||
StructuredQuota(userID, kind string, creating bool, deltaBytes int64) error
|
||||
CanvasQuotaCheck(userID string) (func(*repository.Repository, bool, int64) error, error)
|
||||
StructuredReplacementQuota(userID, kind string, count int, bytes int64) error
|
||||
DeleteUserAssetWithResources(userID, assetID string, purge bool) error
|
||||
PurgeUserAssetsWithResources(userID string, assetIDs []string) error
|
||||
@@ -36,6 +37,9 @@ func (nopHost) WithStorageLock(fn func() error) error {
|
||||
}
|
||||
return fn()
|
||||
}
|
||||
func (nopHost) CanvasQuotaCheck(string) (func(*repository.Repository, bool, int64) error, error) {
|
||||
return func(*repository.Repository, bool, int64) error { return nil }, nil
|
||||
}
|
||||
func (nopHost) StructuredQuota(string, string, bool, int64) error { return nil }
|
||||
func (nopHost) StructuredReplacementQuota(string, string, int, int64) error { return nil }
|
||||
func (nopHost) DeleteUserAssetWithResources(string, string, bool) error { return nil }
|
||||
|
||||
460
backend/internal/canvas/operations.go
Normal file
460
backend/internal/canvas/operations.go
Normal file
@@ -0,0 +1,460 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var canvasOperationFields = []string{"title", "projectId", "chatSessions", "activeChatId", "starterMode", "appearance", "backgroundMode", "showImageInfo", "previsScenes", "timeline"}
|
||||
var canvasOptionalOperationFields = map[string]struct{}{"projectId": {}, "starterMode": {}, "appearance": {}, "timeline": {}}
|
||||
|
||||
// CanvasOperationsRequest is the durable write envelope shared by the editor,
|
||||
// Agent and media callbacks. MutationID is the retry identity: a lost response
|
||||
// must be replayed with the same ID and payload.
|
||||
type CanvasOperationsRequest struct {
|
||||
MutationID string `json:"mutationId"`
|
||||
BaseRevision *int64 `json:"baseRevision,omitempty"`
|
||||
Operations []CanvasOperation `json:"operations"`
|
||||
TrustedServerMutation bool `json:"-"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrCanvasOperationConflict = errors.New("画布操作冲突")
|
||||
ErrCanvasObjectMissing = errors.New("画布对象不存在")
|
||||
)
|
||||
|
||||
type CanvasOperation struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Node json.RawMessage `json:"node,omitempty"`
|
||||
Connection json.RawMessage `json:"connection,omitempty"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
Patch map[string]json.RawMessage `json:"patch,omitempty"`
|
||||
Before map[string]json.RawMessage `json:"before,omitempty"`
|
||||
BeforeValue json.RawMessage `json:"beforeValue,omitempty"`
|
||||
Expected json.RawMessage `json:"expected,omitempty"`
|
||||
}
|
||||
|
||||
func CanvasOperationsDigest(req CanvasOperationsRequest) string {
|
||||
raw, _ := json.Marshal(req)
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// DiffCanvasDocuments turns a full document into object-scoped operations.
|
||||
// Existing nodes are patched by leaf path so concurrent writers can merge
|
||||
// unrelated fields without replacing an entire node.
|
||||
func DiffCanvasDocuments(before, after map[string]any) []CanvasOperation {
|
||||
operations := make([]CanvasOperation, 0)
|
||||
beforeNodes := indexedCanvasObjects(before["nodes"])
|
||||
afterNodes := indexedCanvasObjects(after["nodes"])
|
||||
for _, node := range orderedCanvasObjects(after["nodes"]) {
|
||||
id := canvasString(node["id"])
|
||||
previous, exists := beforeNodes[id]
|
||||
if !exists {
|
||||
operations = append(operations, CanvasOperation{Type: "create_node", Node: jsonRawValue(node)})
|
||||
continue
|
||||
}
|
||||
patch, previousValues := diffCanvasObject(previous, node, "")
|
||||
if len(patch) > 0 {
|
||||
operations = append(operations, CanvasOperation{Type: "patch_node", ID: id, Patch: patch, Before: previousValues})
|
||||
}
|
||||
}
|
||||
for _, node := range orderedCanvasObjects(before["nodes"]) {
|
||||
id := canvasString(node["id"])
|
||||
if _, exists := afterNodes[id]; !exists {
|
||||
operations = append(operations, CanvasOperation{Type: "remove_node", ID: id, Expected: jsonRawValue(node)})
|
||||
}
|
||||
}
|
||||
beforeEdges := indexedCanvasObjects(before["connections"])
|
||||
afterEdges := indexedCanvasObjects(after["connections"])
|
||||
for _, edge := range orderedCanvasObjects(after["connections"]) {
|
||||
id := canvasString(edge["id"])
|
||||
if previous, exists := beforeEdges[id]; !exists {
|
||||
operations = append(operations, CanvasOperation{Type: "create_connection", Connection: jsonRawValue(edge)})
|
||||
} else if !jsonEqual(previous, edge) {
|
||||
operations = append(operations, CanvasOperation{Type: "update_connection", ID: id, Connection: jsonRawValue(edge), Expected: jsonRawValue(previous)})
|
||||
}
|
||||
}
|
||||
for _, edge := range orderedCanvasObjects(before["connections"]) {
|
||||
id := canvasString(edge["id"])
|
||||
if _, exists := afterEdges[id]; !exists {
|
||||
operations = append(operations, CanvasOperation{Type: "remove_connection", ID: id, Expected: jsonRawValue(edge)})
|
||||
}
|
||||
}
|
||||
for _, field := range canvasOperationFields {
|
||||
if !jsonEqual(before[field], after[field]) {
|
||||
operations = append(operations, CanvasOperation{Type: "set_field", Field: field, Value: jsonRawValue(after[field]), BeforeValue: jsonRawValue(before[field])})
|
||||
}
|
||||
}
|
||||
return operations
|
||||
}
|
||||
|
||||
func orderedCanvasObjects(value any) []map[string]any {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var values []map[string]any
|
||||
if json.Unmarshal(raw, &values) != nil {
|
||||
return nil
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func indexedCanvasObjects(value any) map[string]map[string]any {
|
||||
result := map[string]map[string]any{}
|
||||
for _, item := range orderedCanvasObjects(value) {
|
||||
if id := canvasString(item["id"]); id != "" {
|
||||
result[id] = item
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func canvasString(value any) string {
|
||||
text, _ := value.(string)
|
||||
return text
|
||||
}
|
||||
|
||||
func diffCanvasObject(before, after map[string]any, prefix string) (map[string]json.RawMessage, map[string]json.RawMessage) {
|
||||
patch, previous := map[string]json.RawMessage{}, map[string]json.RawMessage{}
|
||||
keys := map[string]bool{}
|
||||
for key := range before {
|
||||
keys[key] = true
|
||||
}
|
||||
for key := range after {
|
||||
keys[key] = true
|
||||
}
|
||||
ordered := make([]string, 0, len(keys))
|
||||
for key := range keys {
|
||||
ordered = append(ordered, key)
|
||||
}
|
||||
sort.Strings(ordered)
|
||||
for _, key := range ordered {
|
||||
path := key
|
||||
if prefix != "" {
|
||||
path = prefix + "." + key
|
||||
}
|
||||
oldValue, oldExists := before[key]
|
||||
newValue, newExists := after[key]
|
||||
if oldExists && newExists {
|
||||
oldObject, oldOK := oldValue.(map[string]any)
|
||||
newObject, newOK := newValue.(map[string]any)
|
||||
if oldOK && newOK {
|
||||
nested, nestedBefore := diffCanvasObject(oldObject, newObject, path)
|
||||
for k, v := range nested {
|
||||
patch[k] = v
|
||||
}
|
||||
for k, v := range nestedBefore {
|
||||
previous[k] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
if jsonEqual(oldValue, newValue) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if oldExists {
|
||||
previous[path] = jsonRawValue(oldValue)
|
||||
} else {
|
||||
previous[path] = json.RawMessage("null")
|
||||
}
|
||||
if newExists {
|
||||
patch[path] = jsonRawValue(newValue)
|
||||
} else {
|
||||
patch[path] = json.RawMessage("null")
|
||||
}
|
||||
}
|
||||
return patch, previous
|
||||
}
|
||||
|
||||
func setCanvasPath(target map[string]any, path string, raw json.RawMessage) error {
|
||||
parts := strings.Split(path, ".")
|
||||
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
|
||||
return errors.New("节点字段路径无效")
|
||||
}
|
||||
current := target
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
next, ok := current[part].(map[string]any)
|
||||
if !ok {
|
||||
next = map[string]any{}
|
||||
current[part] = next
|
||||
}
|
||||
current = next
|
||||
}
|
||||
key := parts[len(parts)-1]
|
||||
if string(raw) == "null" {
|
||||
delete(current, key)
|
||||
return nil
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
current[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func canvasOperationConflict(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrCanvasOperationConflict, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func canvasObjectMissing(id string) error {
|
||||
return fmt.Errorf("%w: 节点 %q 已删除,迟到更新未应用", ErrCanvasObjectMissing, id)
|
||||
}
|
||||
|
||||
func canvasPathValue(target map[string]any, path string) (any, bool) {
|
||||
parts := strings.Split(path, ".")
|
||||
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
|
||||
return nil, false
|
||||
}
|
||||
var current any = target
|
||||
for _, part := range parts {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
value, exists := object[part]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
current = value
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func canvasExpectedValueMatches(actual any, exists bool, expected json.RawMessage) bool {
|
||||
if len(expected) == 0 {
|
||||
return true
|
||||
}
|
||||
if string(expected) == "null" {
|
||||
return !exists || actual == nil
|
||||
}
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
var expectedValue any
|
||||
if json.Unmarshal(expected, &expectedValue) != nil {
|
||||
return false
|
||||
}
|
||||
return jsonEqual(actual, expectedValue)
|
||||
}
|
||||
|
||||
func canvasObjectExpectedMatches(actual map[string]any, expected json.RawMessage) bool {
|
||||
if len(expected) == 0 {
|
||||
return true
|
||||
}
|
||||
var expectedValue map[string]any
|
||||
if json.Unmarshal(expected, &expectedValue) != nil {
|
||||
return false
|
||||
}
|
||||
return jsonEqual(actual, expectedValue)
|
||||
}
|
||||
|
||||
func applyCanvasOperations(payload map[string]json.RawMessage, operations []CanvasOperation) error {
|
||||
nodes := orderedCanvasObjects(rawJSON(payload["nodes"]))
|
||||
connections := orderedCanvasObjects(rawJSON(payload["connections"]))
|
||||
for _, op := range operations {
|
||||
switch op.Type {
|
||||
case "create_node":
|
||||
var node map[string]any
|
||||
if err := json.Unmarshal(op.Node, &node); err != nil || canvasString(node["id"]) == "" {
|
||||
return fmt.Errorf("节点操作缺少有效节点")
|
||||
}
|
||||
id := canvasString(node["id"])
|
||||
if indexOfCanvasObject(nodes, id) >= 0 {
|
||||
return canvasOperationConflict("节点 %q 已存在,显式新增不能覆盖已有节点", id)
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
case "patch_node":
|
||||
if strings.TrimSpace(op.ID) == "" {
|
||||
return fmt.Errorf("节点补丁缺少 ID")
|
||||
}
|
||||
if len(op.Patch) == 0 || len(op.Before) == 0 {
|
||||
return fmt.Errorf("节点补丁缺少字段基线")
|
||||
}
|
||||
index := indexOfCanvasObject(nodes, op.ID)
|
||||
if index < 0 {
|
||||
return canvasObjectMissing(op.ID)
|
||||
}
|
||||
for path, expected := range op.Before {
|
||||
if path == "id" {
|
||||
return fmt.Errorf("节点 ID 不可修改")
|
||||
}
|
||||
actual, exists := canvasPathValue(nodes[index], path)
|
||||
if !canvasExpectedValueMatches(actual, exists, expected) {
|
||||
return canvasOperationConflict("节点 %q 的字段 %q 已被其他写入修改", op.ID, path)
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(op.Patch))
|
||||
for key := range op.Patch {
|
||||
if key == "id" {
|
||||
return fmt.Errorf("节点 ID 不可修改")
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, path := range keys {
|
||||
if err := setCanvasPath(nodes[index], path, op.Patch[path]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "remove_node":
|
||||
if strings.TrimSpace(op.ID) == "" {
|
||||
return fmt.Errorf("删除节点缺少 ID")
|
||||
}
|
||||
if len(op.Expected) == 0 {
|
||||
return fmt.Errorf("删除节点缺少对象基线")
|
||||
}
|
||||
index := indexOfCanvasObject(nodes, op.ID)
|
||||
if index < 0 {
|
||||
continue
|
||||
}
|
||||
if !canvasObjectExpectedMatches(nodes[index], op.Expected) {
|
||||
return canvasOperationConflict("节点 %q 已被其他写入修改,拒绝删除", op.ID)
|
||||
}
|
||||
nodes = append(nodes[:index], nodes[index+1:]...)
|
||||
next := connections[:0]
|
||||
for _, edge := range connections {
|
||||
if canvasString(edge["fromNodeId"]) != op.ID && canvasString(edge["toNodeId"]) != op.ID {
|
||||
next = append(next, edge)
|
||||
}
|
||||
}
|
||||
connections = next
|
||||
case "create_connection":
|
||||
var edge map[string]any
|
||||
if err := json.Unmarshal(op.Connection, &edge); err != nil || canvasString(edge["id"]) == "" {
|
||||
return fmt.Errorf("连线操作缺少有效连线")
|
||||
}
|
||||
fromID := canvasString(edge["fromNodeId"])
|
||||
toID := canvasString(edge["toNodeId"])
|
||||
if fromID == "" || toID == "" || indexOfCanvasObject(nodes, fromID) < 0 || indexOfCanvasObject(nodes, toID) < 0 {
|
||||
return fmt.Errorf("连线端点节点不存在")
|
||||
}
|
||||
id := canvasString(edge["id"])
|
||||
if indexOfCanvasObject(connections, id) >= 0 {
|
||||
return canvasOperationConflict("连线 %q 已存在,显式新增不能覆盖已有连线", id)
|
||||
}
|
||||
connections = append(connections, edge)
|
||||
case "update_connection":
|
||||
var edge map[string]any
|
||||
if err := json.Unmarshal(op.Connection, &edge); err != nil || canvasString(edge["id"]) == "" {
|
||||
return fmt.Errorf("连线更新操作缺少有效连线")
|
||||
}
|
||||
if strings.TrimSpace(op.ID) == "" || op.ID != canvasString(edge["id"]) {
|
||||
return fmt.Errorf("连线操作 ID 与对象 ID 不一致")
|
||||
}
|
||||
if len(op.Expected) == 0 {
|
||||
return fmt.Errorf("连线更新缺少对象基线")
|
||||
}
|
||||
fromID := canvasString(edge["fromNodeId"])
|
||||
toID := canvasString(edge["toNodeId"])
|
||||
if fromID == "" || toID == "" || indexOfCanvasObject(nodes, fromID) < 0 || indexOfCanvasObject(nodes, toID) < 0 {
|
||||
return fmt.Errorf("连线端点节点不存在")
|
||||
}
|
||||
index := indexOfCanvasObject(connections, op.ID)
|
||||
if index < 0 {
|
||||
return canvasObjectMissing("连线 " + op.ID)
|
||||
}
|
||||
if !canvasObjectExpectedMatches(connections[index], op.Expected) {
|
||||
return canvasOperationConflict("连线 %q 已被其他写入修改,拒绝覆盖", op.ID)
|
||||
}
|
||||
connections[index] = edge
|
||||
case "remove_connection":
|
||||
if strings.TrimSpace(op.ID) == "" {
|
||||
return fmt.Errorf("删除连线缺少 ID")
|
||||
}
|
||||
if len(op.Expected) == 0 {
|
||||
return fmt.Errorf("删除连线缺少对象基线")
|
||||
}
|
||||
index := indexOfCanvasObject(connections, op.ID)
|
||||
if index < 0 {
|
||||
continue
|
||||
}
|
||||
if !canvasObjectExpectedMatches(connections[index], op.Expected) {
|
||||
return canvasOperationConflict("连线 %q 已被其他写入修改,拒绝删除", op.ID)
|
||||
}
|
||||
connections = append(connections[:index], connections[index+1:]...)
|
||||
case "set_field":
|
||||
if !allowedCanvasOperationField(op.Field) {
|
||||
return fmt.Errorf("不支持的画布字段操作: 字段 %q", op.Field)
|
||||
}
|
||||
// Older clients serialized an undefined optional field by omitting the
|
||||
// property entirely. Treat that wire form as JSON null so the original
|
||||
// mutation ID and payload can be replayed safely after an upgrade. The
|
||||
// expected-value check below still rejects the write when the server
|
||||
// field is present, so this compatibility path cannot overwrite a value.
|
||||
value := op.Value
|
||||
if len(value) == 0 {
|
||||
if _, optional := canvasOptionalOperationFields[op.Field]; !optional {
|
||||
return fmt.Errorf("画布字段操作缺少 value: 字段 %q", op.Field)
|
||||
}
|
||||
value = json.RawMessage("null")
|
||||
}
|
||||
beforeValue := op.BeforeValue
|
||||
if len(beforeValue) == 0 {
|
||||
if _, optional := canvasOptionalOperationFields[op.Field]; !optional {
|
||||
return fmt.Errorf("画布字段操作缺少 beforeValue: 字段 %q", op.Field)
|
||||
}
|
||||
beforeValue = json.RawMessage("null")
|
||||
}
|
||||
if !canvasExpectedValueMatches(rawJSON(payload[op.Field]), payload[op.Field] != nil, beforeValue) {
|
||||
return canvasOperationConflict("画布字段 %q 已被其他写入修改", op.Field)
|
||||
}
|
||||
if string(value) == "null" {
|
||||
delete(payload, op.Field)
|
||||
} else {
|
||||
payload[op.Field] = append(json.RawMessage(nil), value...)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("不支持的画布操作")
|
||||
}
|
||||
}
|
||||
payload["nodes"], _ = json.Marshal(nodes)
|
||||
payload["connections"], _ = json.Marshal(connections)
|
||||
return nil
|
||||
}
|
||||
|
||||
func rawJSON(raw json.RawMessage) any {
|
||||
if len(raw) == 0 {
|
||||
return []any{}
|
||||
}
|
||||
var value any
|
||||
if json.Unmarshal(raw, &value) != nil {
|
||||
return []any{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func jsonRawValue(value any) json.RawMessage {
|
||||
raw, _ := json.Marshal(value)
|
||||
return raw
|
||||
}
|
||||
|
||||
func jsonEqual(left, right any) bool { return reflect.DeepEqual(left, right) }
|
||||
|
||||
func indexOfCanvasObject(items []map[string]any, id string) int {
|
||||
for i, item := range items {
|
||||
if canvasString(item["id"]) == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func allowedCanvasOperationField(field string) bool {
|
||||
for _, allowed := range canvasOperationFields {
|
||||
if field == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
310
backend/internal/canvas/operations_test.go
Normal file
310
backend/internal/canvas/operations_test.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"yingce/backend/internal/kernel"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
func TestApplyCanvasOperationsReplaysSameMutationAndRejectsDifferentPayload(t *testing.T) {
|
||||
svc := newCanvasOperationTestService(t)
|
||||
if err := svc.repo.Create(&model.CanvasProject{
|
||||
ID: "canvas", UserID: "user", Title: "初始", PayloadJSON: `{"nodes":[{"id":"node","metadata":{"title":"旧","prompt":"原始"}}],"connections":[]}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := CanvasOperationsRequest{
|
||||
MutationID: "mutation-1",
|
||||
Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{
|
||||
"metadata.title": json.RawMessage(`"新"`),
|
||||
}, Before: map[string]json.RawMessage{"metadata.title": json.RawMessage(`"旧"`)}}},
|
||||
}
|
||||
first, err := svc.ApplyUserCanvasOperations("user", "canvas", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Revision != 2 {
|
||||
t.Fatalf("first revision = %d, want 2", first.Revision)
|
||||
}
|
||||
second, err := svc.ApplyUserCanvasOperations("user", "canvas", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Revision != first.Revision {
|
||||
t.Fatalf("replay revision = %d, want %d", second.Revision, first.Revision)
|
||||
}
|
||||
receipt, err := svc.repo.CanvasMutationReceiptForUser("user", "canvas", "mutation-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receipt.Revision != first.Revision || receipt.PayloadDigest == "" || receipt.ResultJSON == "" {
|
||||
t.Fatalf("invalid receipt: %#v", receipt)
|
||||
}
|
||||
|
||||
request.Operations[0].Patch["metadata.title"] = json.RawMessage(`"冲突"`)
|
||||
_, err = svc.ApplyUserCanvasOperations("user", "canvas", request)
|
||||
var appError *kernel.AppError
|
||||
if !errors.As(err, &appError) || appError.Status != http.StatusConflict {
|
||||
t.Fatalf("different payload replay error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCanvasOperationsCreatesOnlyExplicitCanvas(t *testing.T) {
|
||||
svc := newCanvasOperationTestService(t)
|
||||
request := CanvasOperationsRequest{MutationID: "create-canvas", Operations: []CanvasOperation{
|
||||
{Type: "create_canvas", ID: "canvas", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"新画布"`)}},
|
||||
{Type: "create_node", Node: json.RawMessage(`{"id":"node","type":"text","metadata":{"content":"hello"}}`)},
|
||||
}}
|
||||
result, err := svc.ApplyUserCanvasOperations("user", "canvas", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Revision != 1 {
|
||||
t.Fatalf("created revision = %d, want 1", result.Revision)
|
||||
}
|
||||
stored, err := svc.repo.CanvasProjectForUser("user", "canvas")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Title != "新画布" || !strings.Contains(stored.PayloadJSON, `"node"`) {
|
||||
t.Fatalf("created canvas payload = %s", stored.PayloadJSON)
|
||||
}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "missing", CanvasOperationsRequest{MutationID: "missing-update", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"late"`)}, Before: map[string]json.RawMessage{"title": json.RawMessage(`null`)}}}}); err == nil {
|
||||
t.Fatal("implicit canvas creation was accepted")
|
||||
}
|
||||
}
|
||||
func TestApplyCanvasOperationsDoesNotRecreateDeletedNode(t *testing.T) {
|
||||
svc := newCanvasOperationTestService(t)
|
||||
if err := svc.repo.Create(&model.CanvasProject{
|
||||
ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[{"id":"node","metadata":{"title":"旧"}}],"connections":[]}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remove := CanvasOperationsRequest{MutationID: "remove-node", Operations: []CanvasOperation{{Type: "remove_node", ID: "node", Expected: json.RawMessage(`{"id":"node","metadata":{"title":"旧"}}`)}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", remove); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
late := CanvasOperationsRequest{MutationID: "late-update", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{
|
||||
"metadata.title": json.RawMessage(`"迟到"`),
|
||||
}, Before: map[string]json.RawMessage{"metadata.title": json.RawMessage(`"旧"`)}}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", late); err == nil {
|
||||
t.Fatal("late update after deletion was accepted")
|
||||
} else {
|
||||
var appError *kernel.AppError
|
||||
if !errors.As(err, &appError) || appError.Status != http.StatusConflict {
|
||||
t.Fatalf("late update error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
stored, err := svc.UserCanvasProject("user", "canvas")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload struct {
|
||||
Nodes []map[string]any `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(stored, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Nodes) != 0 {
|
||||
t.Fatalf("late update recreated deleted node: %s", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCanvasOperationsRejectsConnectionWithMissingEndpoint(t *testing.T) {
|
||||
svc := newCanvasOperationTestService(t)
|
||||
if err := svc.repo.Create(&model.CanvasProject{
|
||||
ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[{"id":"from"}],"connections":[]}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := CanvasOperationsRequest{MutationID: "bad-connection", Operations: []CanvasOperation{{
|
||||
Type: "create_connection", Connection: json.RawMessage(`{"id":"edge","fromNodeId":"from","toNodeId":"missing"}`),
|
||||
}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", request); err == nil {
|
||||
t.Fatal("missing connection endpoint was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCanvasOperationsRejectsStaleFieldAndAllowsDisjointField(t *testing.T) {
|
||||
svc := newCanvasOperationTestService(t)
|
||||
if err := svc.repo.Create(&model.CanvasProject{
|
||||
ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[{"id":"node","metadata":{"title":"旧","prompt":"原始"}}],"connections":[]}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := CanvasOperationsRequest{MutationID: "title-change", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{
|
||||
"metadata.title": json.RawMessage(`"标题一"`),
|
||||
}, Before: map[string]json.RawMessage{"metadata.title": json.RawMessage(`"旧"`)}}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := CanvasOperationsRequest{MutationID: "stale-title", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{
|
||||
"metadata.title": json.RawMessage(`"标题二"`),
|
||||
}, Before: map[string]json.RawMessage{"metadata.title": json.RawMessage(`"旧"`)}}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", stale); err == nil {
|
||||
t.Fatal("stale same-field update was accepted")
|
||||
} else {
|
||||
var appError *kernel.AppError
|
||||
if !errors.As(err, &appError) || appError.Status != http.StatusConflict {
|
||||
t.Fatalf("stale same-field error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
disjoint := CanvasOperationsRequest{MutationID: "prompt-change", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{
|
||||
"metadata.prompt": json.RawMessage(`"新的提示词"`),
|
||||
}, Before: map[string]json.RawMessage{"metadata.prompt": json.RawMessage(`"原始"`)}}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", disjoint); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCanvasOperationsRejectsStaleCanvasField(t *testing.T) {
|
||||
svc := newCanvasOperationTestService(t)
|
||||
if err := svc.repo.Create(&model.CanvasProject{ID: "canvas", UserID: "user", Title: "旧标题", PayloadJSON: `{"nodes":[],"connections":[],"title":"旧标题"}`}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := CanvasOperationsRequest{MutationID: "field-one", Operations: []CanvasOperation{{Type: "set_field", Field: "title", Value: json.RawMessage(`"新标题"`), BeforeValue: json.RawMessage(`"旧标题"`)}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := CanvasOperationsRequest{MutationID: "field-two", Operations: []CanvasOperation{{Type: "set_field", Field: "title", Value: json.RawMessage(`"另一个标题"`), BeforeValue: json.RawMessage(`"旧标题"`)}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", stale); err == nil {
|
||||
t.Fatal("stale canvas field update was accepted")
|
||||
} else {
|
||||
var appError *kernel.AppError
|
||||
if !errors.As(err, &appError) || appError.Status != http.StatusConflict {
|
||||
t.Fatalf("stale canvas field error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端读到的 projectId/title 来自数据库列投影;payload 里缺省该字段时,
|
||||
// 以投影值作为 beforeValue 的请求必须被接受,而不是误报字段冲突。
|
||||
func TestApplyCanvasOperationsChecksFieldBaseAgainstProjection(t *testing.T) {
|
||||
svc := newCanvasOperationTestService(t)
|
||||
if err := svc.repo.Create(&model.CanvasProject{ID: "canvas", UserID: "user", Title: "自由画布 1", PayloadJSON: `{"nodes":[],"connections":[]}`}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := CanvasOperationsRequest{MutationID: "projection-base", Operations: []CanvasOperation{
|
||||
{Type: "set_field", Field: "projectId", Value: json.RawMessage(`null`), BeforeValue: json.RawMessage(`""`)},
|
||||
{Type: "set_field", Field: "title", Value: json.RawMessage(`"改名"`), BeforeValue: json.RawMessage(`"自由画布 1"`)},
|
||||
}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", request); err != nil {
|
||||
t.Fatalf("projection base rejected: %v", err)
|
||||
}
|
||||
stored, err := svc.repo.CanvasProjectForUser("user", "canvas")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Title != "改名" || stored.ProjectID != "" {
|
||||
t.Fatalf("stored title=%q projectId=%q", stored.Title, stored.ProjectID)
|
||||
}
|
||||
stale := CanvasOperationsRequest{MutationID: "projection-stale", Operations: []CanvasOperation{
|
||||
{Type: "set_field", Field: "title", Value: json.RawMessage(`"再改"`), BeforeValue: json.RawMessage(`"自由画布 1"`)},
|
||||
}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", stale); err == nil {
|
||||
t.Fatal("stale title base accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCanvasOperationsAcceptsLegacyOmittedOptionalFieldValues(t *testing.T) {
|
||||
svc := newCanvasOperationTestService(t)
|
||||
if err := svc.repo.Create(&model.CanvasProject{ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[],"connections":[]}`}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Before the frontend encoded undefined as null, JSON.stringify omitted
|
||||
// beforeValue when an optional field was absent. The server must replay that
|
||||
// original request against an absent field without weakening the precondition.
|
||||
legacyCreate := CanvasOperationsRequest{MutationID: "legacy-optional-field", Operations: []CanvasOperation{{
|
||||
Type: "set_field", Field: "timeline", Value: json.RawMessage(`{"tracks":[]}`),
|
||||
}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", legacyCreate); err != nil {
|
||||
t.Fatalf("legacy optional field update failed: %v", err)
|
||||
}
|
||||
|
||||
stored, err := svc.repo.CanvasProjectForUser("user", "canvas")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stored.PayloadJSON, `"timeline":{"tracks":[]}`) {
|
||||
t.Fatalf("legacy optional field was not stored: %s", stored.PayloadJSON)
|
||||
}
|
||||
|
||||
legacyDelete := CanvasOperationsRequest{MutationID: "legacy-optional-field-delete", Operations: []CanvasOperation{{
|
||||
Type: "set_field", Field: "timeline", BeforeValue: json.RawMessage(`{"tracks":[]}`),
|
||||
}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", legacyDelete); err != nil {
|
||||
t.Fatalf("legacy optional field deletion failed: %v", err)
|
||||
}
|
||||
stored, err = svc.repo.CanvasProjectForUser("user", "canvas")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(stored.PayloadJSON, `"timeline"`) {
|
||||
t.Fatalf("legacy optional field was not deleted: %s", stored.PayloadJSON)
|
||||
}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", CanvasOperationsRequest{MutationID: "missing-required-field-base", Operations: []CanvasOperation{{
|
||||
Type: "set_field", Field: "title", Value: json.RawMessage(`"标题"`),
|
||||
}}}); err == nil {
|
||||
t.Fatal("missing required field precondition was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCanvasOperationsDoesNotRecreateDeletedCanvas(t *testing.T) {
|
||||
svc := newCanvasOperationTestService(t)
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", CanvasOperationsRequest{MutationID: "create", Operations: []CanvasOperation{{Type: "create_canvas", ID: "canvas", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"初始"`)}}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.DeleteUserCanvasProject("user", "canvas"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
late := CanvasOperationsRequest{MutationID: "late-after-canvas-delete", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"迟到"`)}, Before: map[string]json.RawMessage{"title": json.RawMessage(`null`)}}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", late); err == nil {
|
||||
t.Fatal("late update recreated deleted canvas")
|
||||
}
|
||||
createAgain := CanvasOperationsRequest{MutationID: "explicit-recreate", Operations: []CanvasOperation{{Type: "create_canvas", ID: "canvas", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"明确新建"`)}}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", createAgain); err != nil {
|
||||
t.Fatalf("explicit canvas creation after deletion failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCanvasOperationsRequiresObjectPreconditions(t *testing.T) {
|
||||
svc := newCanvasOperationTestService(t)
|
||||
if err := svc.repo.Create(&model.CanvasProject{ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[{"id":"node","title":"旧"}],"connections":[{"id":"edge","fromNodeId":"node","toNodeId":"node","label":"旧"}]}`}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withoutPatchBase := CanvasOperationsRequest{MutationID: "missing-patch-base", Operations: []CanvasOperation{{Type: "patch_node", ID: "node", Patch: map[string]json.RawMessage{"title": json.RawMessage(`"新"`)}}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", withoutPatchBase); err == nil {
|
||||
t.Fatal("patch without field precondition was accepted")
|
||||
}
|
||||
withoutRemoveBase := CanvasOperationsRequest{MutationID: "missing-remove-base", Operations: []CanvasOperation{{Type: "remove_node", ID: "node"}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", withoutRemoveBase); err == nil {
|
||||
t.Fatal("remove without object precondition was accepted")
|
||||
}
|
||||
withoutConnectionBase := CanvasOperationsRequest{MutationID: "missing-connection-base", Operations: []CanvasOperation{{Type: "update_connection", ID: "edge", Connection: json.RawMessage(`{"id":"edge","fromNodeId":"node","toNodeId":"node","label":"新"}`)}}}
|
||||
if _, err := svc.ApplyUserCanvasOperations("user", "canvas", withoutConnectionBase); err == nil {
|
||||
t.Fatal("connection update without object precondition was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func newCanvasOperationTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "canvas-operations.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.CanvasProject{}, &model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}, &model.CanvasMutationReceipt{}, &model.CanvasShare{}, &model.CanvasUnitLink{}, &model.Task{}, &model.Resource{}, &model.Asset{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
return New(repository.New(db), nil)
|
||||
}
|
||||
@@ -234,6 +234,181 @@ func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (U
|
||||
return s.upsertUserCanvasProjectWithHistory(userID, raw, "automatic")
|
||||
}
|
||||
|
||||
type canvasMutationReceiptResult struct {
|
||||
Summary UserDataSummary `json:"summary"`
|
||||
Document json.RawMessage `json:"document"`
|
||||
}
|
||||
|
||||
func decodeCanvasMutationReceipt(raw string, result *UserDataSummary) error {
|
||||
var stored canvasMutationReceiptResult
|
||||
if err := json.Unmarshal([]byte(raw), &stored); err == nil && stored.Summary.ID != "" {
|
||||
*result = stored.Summary
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal([]byte(raw), result)
|
||||
}
|
||||
|
||||
// ApplyUserCanvasOperations applies only the objects changed by the caller to the
|
||||
// latest server document. The request is idempotent by mutation ID and commits
|
||||
// the canvas row plus its receipt in one database transaction.
|
||||
func (s *Service) ApplyUserCanvasOperations(userID, canvasID string, req CanvasOperationsRequest) (UserDataSummary, error) {
|
||||
if canvasID == "" || len(canvasID) > 80 || req.MutationID == "" || len(req.MutationID) > 160 || len(req.Operations) == 0 || len(req.Operations) > 500 {
|
||||
return UserDataSummary{}, kernel.BadAuthRequest("画布操作请求 ID、操作内容或批次大小无效")
|
||||
}
|
||||
if req.BaseRevision != nil && (*req.BaseRevision < 0 || *req.BaseRevision >= 9007199254740991) {
|
||||
return UserDataSummary{}, kernel.BadAuthRequest("画布基线版本无效")
|
||||
}
|
||||
quota, err := s.host.CanvasQuotaCheck(userID)
|
||||
if err != nil {
|
||||
return UserDataSummary{}, err
|
||||
}
|
||||
digest := CanvasOperationsDigest(req)
|
||||
var result UserDataSummary
|
||||
err = s.host.WithStorageLock(func() error {
|
||||
return s.repo.Transaction(func(tx *repository.Repository) error {
|
||||
if err := tx.LockCanvasMutation(userID, canvasID); err != nil {
|
||||
return err
|
||||
}
|
||||
candidate := &model.CanvasMutationReceipt{ID: kernel.NewID(), UserID: userID, CanvasID: canvasID, MutationID: req.MutationID, PayloadDigest: digest}
|
||||
created, err := tx.TryCreateCanvasMutationReceipt(candidate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !created {
|
||||
receipt, err := tx.CanvasMutationReceiptForUser(userID, canvasID, req.MutationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if receipt.PayloadDigest != digest {
|
||||
return canvasMutationError("canvas_mutation_id_reused", "同一画布请求 ID 对应的操作内容不一致")
|
||||
}
|
||||
return decodeCanvasMutationReceipt(receipt.ResultJSON, &result)
|
||||
}
|
||||
existing, err := tx.CanvasProjectForUser(userID, canvasID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
existing = nil
|
||||
}
|
||||
isCreate := existing == nil
|
||||
if isCreate && (len(req.Operations) == 0 || req.Operations[0].Type != "create_canvas") {
|
||||
return kernel.NotFound("画布不存在或已删除")
|
||||
}
|
||||
if !isCreate {
|
||||
for _, operation := range req.Operations {
|
||||
if operation.Type == "create_canvas" {
|
||||
return canvasOperationConflict("画布 %q 已存在,不能重复创建", canvasID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.BaseRevision != nil && *req.BaseRevision > 0 && isCreate {
|
||||
return canvasMutationError("canvas_invalid_base", "新画布不能使用已有版本作为基线")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var payload map[string]json.RawMessage
|
||||
var operations []CanvasOperation
|
||||
if isCreate {
|
||||
create := req.Operations[0]
|
||||
if create.ID != "" && create.ID != canvasID {
|
||||
return kernel.BadAuthRequest("画布创建操作的 ID 与请求路径不一致")
|
||||
}
|
||||
payload = map[string]json.RawMessage{
|
||||
"id": jsonRawValue(canvasID),
|
||||
"nodes": json.RawMessage(`[]`),
|
||||
"connections": json.RawMessage(`[]`),
|
||||
}
|
||||
for field, value := range create.Patch {
|
||||
if !allowedCanvasOperationField(field) {
|
||||
return kernel.BadAuthRequest("不支持的新画布字段")
|
||||
}
|
||||
payload[field] = append(json.RawMessage(nil), value...)
|
||||
}
|
||||
operations = req.Operations[1:]
|
||||
} else {
|
||||
// 客户端的 beforeValue 来自 GET 返回的投影(title/projectId 等以数据库列为准),
|
||||
// 字段基线校验必须对同一份投影进行,否则 projectId 列为 "" 而 payload 缺省时会误判冲突。
|
||||
base, err := canvasProjectPayload(*existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(base, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
operations = req.Operations
|
||||
}
|
||||
if err := applyCanvasOperations(payload, operations); err != nil {
|
||||
if errors.Is(err, ErrCanvasObjectMissing) {
|
||||
return canvasMutationError("canvas_object_missing", err.Error())
|
||||
}
|
||||
if errors.Is(err, ErrCanvasOperationConflict) {
|
||||
return canvasMutationError("canvas_field_conflict", err.Error())
|
||||
}
|
||||
return kernel.BadAuthRequest(err.Error())
|
||||
}
|
||||
delete(payload, "revision")
|
||||
delete(payload, "remoteContentHash")
|
||||
payload["id"] = jsonRawValue(canvasID)
|
||||
payload["updatedAt"] = jsonRawValue(now)
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !req.TrustedServerMutation {
|
||||
if err := New(tx, nil).ValidateCanvasMediaAssets(userID, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
project, err := canvasProjectFromJSONWithValidation(userID, raw, !req.TrustedServerMutation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if project.ID != canvasID {
|
||||
return kernel.BadAuthRequest("画布 ID 与请求路径不一致")
|
||||
}
|
||||
if existing != nil {
|
||||
project.ID, project.CreatedAt, project.Revision, project.UpdatedAt = existing.ID, existing.CreatedAt, existing.Revision, now
|
||||
} else {
|
||||
project.CreatedAt, project.Revision, project.UpdatedAt = now, 0, now
|
||||
}
|
||||
var existingPayload string
|
||||
if existing != nil {
|
||||
existingPayload = existing.PayloadJSON
|
||||
}
|
||||
if err := quota(tx, existing == nil, int64(len(raw)-len(existingPayload))); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.UpsertCanvasProject(&project); err != nil {
|
||||
return err
|
||||
}
|
||||
result = UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, Revision: project.Revision,
|
||||
SaveAudit: &CanvasSaveAudit{NodesBefore: canvasNodeCount(existingPayload), NodesAfter: canvasNodeCount(project.PayloadJSON)}}
|
||||
document, err := canvasProjectPayload(project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.Marshal(canvasMutationReceiptResult{Summary: result, Document: document})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidate.ResultJSON, candidate.Revision = string(encoded), project.Revision
|
||||
return tx.FinalizeCanvasMutationReceipt(candidate)
|
||||
})
|
||||
})
|
||||
if errors.Is(err, repository.ErrCanvasRevisionConflict) {
|
||||
retry := canvasMutationError("canvas_concurrent_write", "画布正在更新,将自动重放原请求")
|
||||
retry.Retryable = true
|
||||
return UserDataSummary{}, retry
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func canvasMutationError(reason kernel.ErrorReason, message string) *kernel.AppError {
|
||||
err := kernel.NewAppError(http.StatusConflict, message)
|
||||
err.Reason = reason
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) upsertUserCanvasProjectWithHistory(userID string, raw json.RawMessage, reason string) (UserDataSummary, error) {
|
||||
var version struct {
|
||||
Revision *int64 `json:"revision"`
|
||||
@@ -378,8 +553,14 @@ func AssetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) {
|
||||
}
|
||||
|
||||
func canvasProjectFromJSON(userID string, raw json.RawMessage) (model.CanvasProject, error) {
|
||||
if err := ValidateSyncedPayload(raw, "画布"); err != nil {
|
||||
return model.CanvasProject{}, err
|
||||
return canvasProjectFromJSONWithValidation(userID, raw, true)
|
||||
}
|
||||
|
||||
func canvasProjectFromJSONWithValidation(userID string, raw json.RawMessage, validate bool) (model.CanvasProject, error) {
|
||||
if validate {
|
||||
if err := ValidateSyncedPayload(raw, "画布"); err != nil {
|
||||
return model.CanvasProject{}, err
|
||||
}
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
Reference in New Issue
Block a user