feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力

This commit is contained in:
ddcat666
2026-10-11 22:19:20 +08:00
parent 6dedc5973c
commit 5f359f2b1d
63 changed files with 3235 additions and 6193 deletions

View File

@@ -441,6 +441,7 @@ func migrations() []tableMigration {
migrateTable[model.WorkflowStepTask]("workflow_step_tasks"),
migrateTable[model.ProductionTaskLink]("production_task_links"),
migrateTable[model.CanvasProject]("canvas_projects"),
migrateTable[model.CanvasMutationReceipt]("canvas_mutation_receipts"),
migrateTable[model.CanvasSnapshot]("canvas_snapshots"),
migrateTable[model.CanvasSnapshotResource]("canvas_snapshot_resources"),
migrateTable[model.CanvasShare]("canvas_shares"),

View File

@@ -2,9 +2,7 @@ package app
import (
"encoding/json"
"errors"
"io"
"time"
"yingce/backend/internal/assets"
"yingce/backend/internal/canvas"
@@ -13,15 +11,17 @@ import (
)
type (
CanvasShareRequest = canvas.CanvasShareRequest
CanvasShareStatus = canvas.CanvasShareStatus
PublicCanvasShare = canvas.PublicCanvasShare
AssetsSyncRequest = canvas.AssetsSyncRequest
CanvasHistoryList = canvas.CanvasHistoryList
UserDataSummary = canvas.UserDataSummary
UserDataSnapshot = canvas.UserDataSnapshot
CanvasLibrarySummary = canvas.CanvasLibrarySummary
CanvasLibraryPage = canvas.CanvasLibraryPage
CanvasShareRequest = canvas.CanvasShareRequest
CanvasShareStatus = canvas.CanvasShareStatus
PublicCanvasShare = canvas.PublicCanvasShare
AssetsSyncRequest = canvas.AssetsSyncRequest
CanvasHistoryList = canvas.CanvasHistoryList
UserDataSummary = canvas.UserDataSummary
UserDataSnapshot = canvas.UserDataSnapshot
CanvasLibrarySummary = canvas.CanvasLibrarySummary
CanvasLibraryPage = canvas.CanvasLibraryPage
CanvasOperationsRequest = canvas.CanvasOperationsRequest
CanvasOperation = canvas.CanvasOperation
)
type canvasHost struct {
@@ -68,6 +68,23 @@ func (h canvasHost) WithStorageLock(fn func() error) error {
return fn()
}
func (h canvasHost) CanvasQuotaCheck(userID string) (func(*repository.Repository, bool, int64) error, error) {
if h.svc == nil {
return func(*repository.Repository, bool, int64) error { return nil }, nil
}
policy, err := h.svc.RuntimePolicy()
if err != nil {
return nil, err
}
return func(repo *repository.Repository, creating bool, deltaBytes int64) error {
usage, err := repo.UserStructuredStorageUsage(userID)
if err != nil {
return err
}
return validateStructuredStorageQuotaWithPolicy(usage, "canvas", creating, deltaBytes, policy.Resource)
}, nil
}
func (h canvasHost) StructuredQuota(userID, kind string, creating bool, deltaBytes int64) error {
if h.svc == nil {
return nil
@@ -76,7 +93,7 @@ func (h canvasHost) StructuredQuota(userID, kind string, creating bool, deltaByt
if err != nil {
return err
}
usage, err := h.svc.repo.UserStorageUsage(userID)
usage, err := h.svc.repo.UserStructuredStorageUsage(userID)
if err != nil {
return err
}
@@ -91,7 +108,7 @@ func (h canvasHost) StructuredReplacementQuota(userID, kind string, count int, b
if err != nil {
return err
}
usage, err := h.svc.repo.UserStorageUsage(userID)
usage, err := h.svc.repo.UserStructuredStorageUsage(userID)
if err != nil {
return err
}
@@ -222,6 +239,10 @@ func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (U
return s.canvasDomain().UpsertUserCanvasProject(userID, raw)
}
func (s *Service) ApplyUserCanvasOperations(userID, canvasID string, req CanvasOperationsRequest) (UserDataSummary, error) {
return s.canvasDomain().ApplyUserCanvasOperations(userID, canvasID, req)
}
func (s *Service) RepairUserCanvasProject(userID string, raw json.RawMessage) (UserDataSummary, error) {
return s.canvasDomain().RepairUserCanvasProject(userID, raw)
}
@@ -250,12 +271,28 @@ func saveCreationCanvasWithHistory(repo *repository.Repository, project *model.C
if before.PayloadJSON != previous || before.Revision != project.Revision {
return repository.ErrCreationConflict
}
project.UpdatedAt = time.Now().UTC()
err = canvas.SaveDocumentWithHistory(repo, before, project, "automatic")
if errors.Is(err, repository.ErrCanvasRevisionConflict) {
return repository.ErrCreationConflict
beforeDoc, err := creationDocument(before.PayloadJSON)
if err != nil {
return err
}
return err
desiredDoc, err := creationDocument(project.PayloadJSON)
if err != nil {
return err
}
operations := canvas.DiffCanvasDocuments(beforeDoc, desiredDoc)
if len(operations) == 0 {
return nil
}
baseRevision := project.Revision
request := canvas.CanvasOperationsRequest{BaseRevision: &baseRevision, Operations: operations, TrustedServerMutation: true}
request.MutationID = "creation-canvas:" + project.ID + ":" + canvas.CanvasOperationsDigest(request)
result, err := canvas.New(repo, nil).ApplyUserCanvasOperations(project.UserID, project.ID, request)
if err != nil {
return err
}
project.Revision = result.Revision
project.UpdatedAt = result.UpdatedAt
return nil
}
func (s *Service) UserAssetsByIDs(userID string, ids []string) ([]json.RawMessage, error) {

View File

@@ -240,7 +240,10 @@ func cloudAgentPatchCoversDocument(before, after map[string]any) bool {
content := func(doc map[string]any) map[string]any {
result := map[string]any{}
for key, value := range doc {
if key != "nodes" && key != "connections" && key != "updatedAt" && key != "viewport" {
if key != "nodes" && key != "connections" && key != "id" && key != "revision" && key != "createdAt" && key != "updatedAt" && key != "viewport" {
if (key == "title" || key == "projectId") && value == "" {
continue
}
result[key] = value
}
}

View File

@@ -96,9 +96,9 @@ func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *te
}
previousRevision = delta["revision"]
}
history, err := s.CanvasHistory("user", "agent-canvas")
if err != nil || len(history.Snapshots) == 0 || float64(history.CurrentRevision) != previousRevision {
t.Fatalf("Agent save did not retain history/revision: %+v %v", history, err)
canvas, err := s.repo.CanvasProjectForUser("user", "agent-canvas")
if err != nil || float64(canvas.Revision) != previousRevision {
t.Fatalf("Agent save did not retain current revision: %+v %v", canvas, err)
}
changes = creationMaps(completion["nodes"])
if len(changes) != 1 || len(creationMaps(completion["connections"])) != 0 {
@@ -126,6 +126,18 @@ func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *te
}
}
func TestCloudAgentPatchCoversProjectedRowMetadata(t *testing.T) {
before := map[string]any{"nodes": []any{}, "connections": []any{}}
after := map[string]any{"id": "canvas", "revision": float64(2), "createdAt": "2026-10-11T00:00:00Z", "updatedAt": "2026-10-11T01:00:00Z", "title": "", "projectId": "", "nodes": []any{}, "connections": []any{}}
if !cloudAgentPatchCoversDocument(before, after) {
t.Fatal("database row metadata and empty projected fields should not force a refresh")
}
after["title"] = "新标题"
if cloudAgentPatchCoversDocument(before, after) {
t.Fatal("a changed canvas title must still force a refresh")
}
}
func TestCloudAgentMediaImageSourceHasActionableCorrection(t *testing.T) {
s, _, args := agentMediaFixture(t)
run, state := agentMediaRun(t, s, args, "request_approval")

View File

@@ -58,8 +58,19 @@ func cloudAgentNodeSnapshotMatches(doc map[string]any, nodeID, snapshotHash stri
func cloudAgentCanvasHash(doc map[string]any) string {
content := make(map[string]any, len(doc))
for key, value := range doc {
if key != "viewport" && key != "updatedAt" {
content[key] = value
switch key {
case "id", "viewport", "updatedAt", "revision", "createdAt":
continue
case "title", "projectId":
if text, ok := value.(string); ok && strings.TrimSpace(text) == "" {
continue
}
}
content[key] = value
}
for _, key := range []string{"nodes", "connections"} {
if value, exists := content[key]; !exists || value == nil {
content[key] = []any{}
}
}
return creationHash(content)

View File

@@ -11,6 +11,7 @@ import (
"unicode/utf8"
"gorm.io/gorm"
"yingce/backend/internal/canvas"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
)
@@ -92,27 +93,29 @@ func (s *Service) cloudAgentModelIntent(userID, canvasID, arguments string) (*Mo
}
type cloudAgentMediaArgs struct {
Prepared *cloudAgentPreparedMedia `json:"-"`
DraftRunID string `json:"-"`
CharacterVersions map[string]string `json:"-"`
CharacterLabels []string `json:"-"`
Mode string `json:"mode"`
Prompt string `json:"prompt"`
LogicalModelID string `json:"logicalModelId"`
ChannelID string `json:"channelId"`
ChannelModelKey string `json:"channelModelKey"`
Duration int `json:"durationSeconds"`
Size string `json:"size"`
Quality string `json:"quality"`
VideoGenerateAudio *bool `json:"videoGenerateAudio"`
VideoStartFrameNodeID string `json:"videoStartFrameNodeId"`
VideoEndFrameNodeID string `json:"videoEndFrameNodeId"`
SnapshotHash string `json:"snapshotHash"`
NodeID string `json:"nodeId"`
Title string `json:"title"`
SourceNodeID string `json:"sourceNodeId"`
ReferenceNodeIDs []string `json:"referenceNodeIds"`
ReferenceTransientIDs []string `json:"referenceTransientIds"`
Prepared *cloudAgentPreparedMedia `json:"-"`
DraftRunID string `json:"-"`
CharacterVersions map[string]string `json:"-"`
CharacterLabels []string `json:"-"`
Mode string `json:"mode"`
Prompt string `json:"prompt"`
LogicalModelID string `json:"logicalModelId"`
ChannelID string `json:"channelId"`
ChannelModelKey string `json:"channelModelKey"`
Duration int `json:"durationSeconds"`
Size string `json:"size"`
Quality string `json:"quality"`
VideoGenerateAudio *bool `json:"videoGenerateAudio"`
VideoWatermark *bool `json:"videoWatermark"`
VideoArkPrivateAssetUpload *bool `json:"videoArkPrivateAssetUpload"`
VideoStartFrameNodeID string `json:"videoStartFrameNodeId"`
VideoEndFrameNodeID string `json:"videoEndFrameNodeId"`
SnapshotHash string `json:"snapshotHash"`
NodeID string `json:"nodeId"`
Title string `json:"title"`
SourceNodeID string `json:"sourceNodeId"`
ReferenceNodeIDs []string `json:"referenceNodeIds"`
ReferenceTransientIDs []string `json:"referenceTransientIds"`
}
type cloudAgentMediaPlan struct {
@@ -537,8 +540,8 @@ func validateCloudAgentMediaArgs(a cloudAgentMediaArgs, state *cloudAgentRuntime
if a.Duration != 0 {
return cloudAgentFieldError("durationSeconds", "invalid_value", "只有视频生成允许设置 durationSeconds;图片或音频生成请省略该字段")
}
if a.VideoGenerateAudio != nil {
return cloudAgentFieldError("videoGenerateAudio", "invalid_value", "videoGenerateAudio 仅适用于视频生成;图片或音频生成请省略该字段")
if a.VideoGenerateAudio != nil || a.VideoWatermark != nil || a.VideoArkPrivateAssetUpload != nil {
return cloudAgentFieldError("videoGenerateAudio", "invalid_value", "视频参数仅适用于视频生成;图片或音频生成请省略该字段")
}
}
if len(a.ReferenceNodeIDs) > 16 {
@@ -711,6 +714,9 @@ func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state *
if a.ChannelID != "" {
config["channelId"], config["channelModelKey"], config["model"] = a.ChannelID, a.ChannelModelKey, a.ChannelModelKey
}
if a.Mode == "video" && a.VideoArkPrivateAssetUpload != nil {
config["videoArkPrivateAssetUpload"] = strconv.FormatBool(*a.VideoArkPrivateAssetUpload)
}
metadata := map[string]any{"nodeId": a.NodeID, "source": "cloud_agent"}
// 首尾帧是结构化输入,不能只写进提示词;复用画布提交任务时的字段。
if a.VideoStartFrameNodeID != "" {
@@ -805,6 +811,20 @@ func applyCloudAgentResolvedMediaDefaults(req *CreateTaskRequest, plan *cloudAge
}
plan.Args.VideoGenerateAudio = &audio
}
if raw := stringValue(config["videoWatermark"]); raw != "" {
watermark, err := strconv.ParseBool(raw)
if err != nil {
return BadAuthRequest("模型目录返回的默认水印参数无效,未提交生成任务")
}
plan.Args.VideoWatermark = &watermark
}
if raw := stringValue(config["videoArkPrivateAssetUpload"]); raw != "" {
privateUpload, err := strconv.ParseBool(raw)
if err != nil {
return BadAuthRequest("模型目录返回的默认参考图同步参数无效,未提交生成任务")
}
plan.Args.VideoArkPrivateAssetUpload = &privateUpload
}
}
plan.Args.Prepared = nil
req.Input = input
@@ -812,7 +832,7 @@ func applyCloudAgentResolvedMediaDefaults(req *CreateTaskRequest, plan *cloudAge
return nil
}
func saveCloudAgentDocument(repo *repository.Repository, canvas *model.CanvasProject, doc map[string]any, policy RuntimePolicySetting) error {
func saveCloudAgentDocument(repo *repository.Repository, project *model.CanvasProject, doc map[string]any, policy RuntimePolicySetting) error {
doc["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
raw, err := json.Marshal(doc)
if err != nil {
@@ -821,16 +841,98 @@ func saveCloudAgentDocument(repo *repository.Repository, canvas *model.CanvasPro
if len(raw) > 8<<20 {
return BadAuthRequest("画布大小超限")
}
usage, err := repo.UserStorageUsage(canvas.UserID)
usage, err := repo.UserStorageUsage(project.UserID)
if err != nil {
return err
}
if err = validateStructuredStorageQuotaWithPolicy(usage, "canvas", false, int64(len(raw)-len(canvas.PayloadJSON)), policy.Resource); err != nil {
if err = validateStructuredStorageQuotaWithPolicy(usage, "canvas", false, int64(len(raw)-len(project.PayloadJSON)), policy.Resource); err != nil {
return err
}
before := canvas.PayloadJSON
canvas.PayloadJSON = string(raw)
return saveCreationCanvasWithHistory(repo, canvas, before)
beforeDoc, err := creationDocument(project.PayloadJSON)
if err != nil {
return err
}
operations := canvas.DiffCanvasDocuments(beforeDoc, doc)
if len(operations) == 0 {
return nil
}
baseRevision := project.Revision
request := canvas.CanvasOperationsRequest{BaseRevision: &baseRevision, Operations: operations, TrustedServerMutation: true}
request.MutationID = "agent-canvas:" + project.ID + ":" + canvas.CanvasOperationsDigest(request)
_, err = canvas.New(repo, nil).ApplyUserCanvasOperations(project.UserID, project.ID, request)
if err != nil {
return err
}
latest, err := repo.CanvasProjectForUser(project.UserID, project.ID)
if err != nil {
return err
}
*project = *latest
persistedDoc, err := creationDocument(latest.PayloadJSON)
if err != nil {
return err
}
for key := range doc {
delete(doc, key)
}
for key, value := range persistedDoc {
doc[key] = value
}
return nil
}
func rebaseCloudAgentDocument(base, desired, latest map[string]any) map[string]any {
result := make(map[string]any, len(latest))
for key, value := range latest {
result[key] = value
}
for _, key := range []string{"nodes", "connections"} {
result[key] = rebaseCloudAgentCollection(base[key], desired[key], latest[key])
}
for key, value := range desired {
if key == "nodes" || key == "connections" || key == "revision" || key == "updatedAt" || key == "createdAt" || key == "id" {
continue
}
if !jsonValuesEqual(base[key], value) {
result[key] = value
}
}
return result
}
func rebaseCloudAgentCollection(baseValue, desiredValue, latestValue any) []map[string]any {
baseItems, desiredItems, latestItems := creationMaps(baseValue), creationMaps(desiredValue), creationMaps(latestValue)
baseByID, desiredByID := map[string]map[string]any{}, map[string]map[string]any{}
for _, item := range baseItems {
baseByID[stringValue(item["id"])] = item
}
for _, item := range desiredItems {
desiredByID[stringValue(item["id"])] = item
}
result := make([]map[string]any, 0, len(latestItems)+len(desiredItems))
seen := map[string]bool{}
for _, item := range latestItems {
id := stringValue(item["id"])
if desired, changed := desiredByID[id]; changed && !jsonValuesEqual(baseByID[id], desired) {
result = append(result, desired)
} else if _, removed := baseByID[id]; !removed || desiredByID[id] != nil {
result = append(result, item)
}
seen[id] = true
}
for _, item := range desiredItems {
id := stringValue(item["id"])
if !seen[id] && !jsonValuesEqual(baseByID[id], item) {
result = append(result, item)
}
}
return result
}
func jsonValuesEqual(left, right any) bool {
leftJSON, leftErr := json.Marshal(left)
rightJSON, rightErr := json.Marshal(right)
return leftErr == nil && rightErr == nil && string(leftJSON) == string(rightJSON)
}
// createCloudAgentMediaNode 为指定用户的画布保存计划中的媒体节点及引用连线。

View File

@@ -1947,15 +1947,20 @@ func applyCloudAgentPrevisMutation(repo *repository.Repository, userID, canvasID
if err = saveCloudAgentDocument(repo, plan.Canvas, plan.Document, policy); err != nil {
return nil, err
}
afterCanvasHash := cloudAgentCanvasHash(plan.Document)
afterSceneHash := plan.ResultSnapshotHash
if scene, ok := findPrevisScene(creationMaps(plan.Document["previsScenes"]), plan.SceneID); ok {
afterSceneHash = creationHash(scene)
}
if len(recorder) > 0 && recorder[0] != nil {
preview := plan.Preview
if err := recorder[0](repo, cloudAgentMutationInput{UserID: userID, CanvasID: canvasID, StepID: call.ID, Operation: plan.Operation, BeforeSnapshotHash: plan.BeforeSnapshotHash, AfterSnapshotHash: plan.AfterSnapshotHash, BeforeJSON: plan.BeforeJSON, Preview: &preview}); err != nil {
if err := recorder[0](repo, cloudAgentMutationInput{UserID: userID, CanvasID: canvasID, StepID: call.ID, Operation: plan.Operation, BeforeSnapshotHash: plan.BeforeSnapshotHash, AfterSnapshotHash: afterCanvasHash, BeforeJSON: plan.BeforeJSON, Preview: &preview}); err != nil {
return nil, err
}
}
resultSnapshotHash := plan.ResultSnapshotHash
resultSnapshotHash := afterSceneHash
if resultSnapshotHash == "" {
resultSnapshotHash = plan.AfterSnapshotHash
resultSnapshotHash = afterCanvasHash
}
return map[string]any{"canvasId": canvasID, "sceneId": plan.SceneID, "nodeId": plan.NodeID, "shotId": plan.ShotID, "snapshotHash": resultSnapshotHash, "canvasSnapshotHash": plan.AfterSnapshotHash, "committed": true, "summary": plan.Preview.Description}, nil
return map[string]any{"canvasId": canvasID, "sceneId": plan.SceneID, "nodeId": plan.NodeID, "shotId": plan.ShotID, "snapshotHash": resultSnapshotHash, "canvasSnapshotHash": afterCanvasHash, "committed": true, "summary": plan.Preview.Description}, nil
}

View File

@@ -61,8 +61,9 @@ func TestUndoCloudAgentCanvasRestoresLatestMutationAndIsIdempotent(t *testing.T)
t.Fatalf("unexpected undo result: %+v", result)
}
restored, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
if restored.PayloadJSON != canvas.PayloadJSON {
t.Fatal("undo did not restore the exact before snapshot")
restoredDoc, err := creationDocument(restored.PayloadJSON)
if err != nil || cloudAgentCanvasHash(restoredDoc) != beforeHash {
t.Fatal("undo did not restore the semantic before snapshot")
}
result, err = s.UndoCloudAgentCanvas("user", run.ID, call.ID, beforeHash, "重复请求")
if err != nil || result["accepted"] != true {

View File

@@ -46,7 +46,7 @@ func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, pur
// 删除语义(产品约定,勿收紧):
// - purge=true 是用户在素材库确认后的「彻底删除」(单个 DELETE、批量删除、清空回收站),
// 不因画布、任务、项目或画布历史快照仍引用而拒绝;旧引用随之失效,画布保存时由
// repairMissingResources 修复断链。
// 由统一 operations 提交前的资源完整性校验修复断链。
// - purge=false 是内部普通删除:回收站(archived)素材同样直接删除,其余素材保留引用保护。
// - 无论哪种,被其他素材复用的资源与物理对象都保留,物理删除一律经 Outbox 异步执行。
deleteReferencedResources := purge

View File

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

View File

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

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

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

View File

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

View File

@@ -11,7 +11,7 @@ import (
"gorm.io/gorm"
)
const CurrentSchemaVersion int64 = 49
const CurrentSchemaVersion int64 = 50
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
@@ -163,6 +163,9 @@ var schemaMigrations = []migration{
}
return nil
}},
{version: 50, name: "canvas_mutation_receipts", checksum: "sha256:canvas-mutation-receipts-v50-20261011", apply: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.CanvasMutationReceipt{})
}},
}
func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error {

View File

@@ -102,6 +102,7 @@ func Models() []any {
&model.CanvasProject{},
&model.CanvasSnapshot{},
&model.CanvasSnapshotResource{},
&model.CanvasMutationReceipt{},
&model.CanvasShare{},
&model.PromptTemplate{},
&model.UserPromptCustomization{},

View File

@@ -376,99 +376,74 @@ paths:
responses:
"200":
description: 信封 data.project 包含完整画布及服务端 revision
"304":
description: If-None-Match 命中当前 revision,正文为空
"404":
description: 画布不存在或不属于当前用户
put:
delete:
tags: [canvas]
summary: 按 revision 保存画布内容
description: 新建传 revision 0,更新传读取到的版本;成功原子加 1。忽略客户端 viewport 和 remoteContentHash,updatedAt 由服务端生成。
summary: 显式删除当前用户画布
description: 删除后迟到的媒体回写和编辑操作不会重新创建画布;只有新的 create_canvas operation 可以显式创建同 ID 画布。
responses:
"200":
description: 画布已删除,信封 data.id 返回画布 ID
"401":
description: 未登录
"404":
description: 画布不存在或不属于当前用户
/canvas-projects/{id}/operations:
parameters:
- in: path
name: id
required: true
schema: { type: string }
patch:
tags: [canvas]
summary: 按对象或字段变更写入画布
description: |
请求只提交对象级或字段级 operations,不提交整批画布快照。
mutationId 是一次请求的幂等身份;响应丢失、断网、刷新或后台重试时必须复用同一个 mutationId 与原始 operations。
更新、删除和连接操作要求目标存在并通过对象基线校验;同一字段冲突不会覆盖远端修改。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [project]
required: [mutationId, operations]
properties:
project:
type: object
required: [id, revision]
properties:
id: { type: string }
revision: { type: integer, format: int64, minimum: 0 }
additionalProperties: true
mutationId:
type: string
minLength: 1
maxLength: 160
description: 一次写入请求的幂等身份
baseRevision:
type: integer
format: int64
minimum: 0
description: 生成 operations 时已确认的画布 revision,用于诊断和响应
operations:
type: array
minItems: 1
maxItems: 500
items:
type: object
required: [type]
additionalProperties: true
description: create_canvas、create_node、patch_node、remove_node、create_connection、update_connection、remove_connection 或 set_field
responses:
"200":
description: 信封 data.project 返回摘要及递增后的 revision
description: 信封 data.project 返回当前画布摘要,data.mutationId 返回原 mutationId
"400":
description: 请求、版本或素材引用不合法
description: 请求、字段、对象基线或素材引用格式不合法
"401":
description: 未登录
"404":
description: 画布不存在或不属于当前用户,且请求未包含显式 create_canvas
"409":
description: 版本或资源状态冲突,原内容未被覆盖;保留草稿后加载最新版
description: 对象并发冲突或同一 mutationId 对应的请求摘要不同;原事务不会被覆盖
"428":
description: 缺少 revision,需刷新客户端并重新读取
/canvas-projects/{id}/history:
get:
tags: [canvas]
summary: 读取当前画布版本与最近 20 份历史摘要
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
"200":
description: 信封 data 包含 currentRevision 和 snapshots;每项含 id、revision、title、nodeCount、connectionCount、payloadBytes、reason、contentUpdatedAt、createdAt,不含正文
"404":
description: 画布不存在或无权访问
/canvas-projects/{id}/history/{snapshotId}:
get:
tags: [canvas]
summary: 按需读取历史正文
parameters:
- in: path
name: id
required: true
schema: { type: string }
- in: path
name: snapshotId
required: true
schema: { type: string }
responses:
"200":
description: 信封 data 包含 snapshot 摘要和 project 正文
"404":
description: 画布或快照不存在、已过期或无权访问
/canvas-projects/{id}/history/{snapshotId}/restore:
post:
tags: [canvas]
summary: 备份当前内容并将历史内容恢复为新版本
parameters:
- in: path
name: id
required: true
schema: { type: string }
- in: path
name: snapshotId
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [revision]
properties:
revision: { type: integer, format: int64, minimum: 1 }
responses:
"200":
description: 信封 data.project 返回恢复后的摘要及新 revision;当前 ID、归属与项目关联保留
"404":
description: 画布或快照不存在、已过期或无权访问
"409":
description: 当前版本或素材状态已变化,恢复未执行
"428":
description: 缺少当前 revision
description: 缺少执行所需的对象基线
/public/welcome:
get:
tags: [meta]

View File

@@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -557,59 +556,7 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
}
okCanvasProject(c, project)
})
r.GET("/canvas-projects/:id/history", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
result, err := svc.CanvasHistory(user.ID, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
r.GET("/canvas-projects/:id/history/:snapshotId", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
snapshot, err := svc.CanvasHistorySnapshot(user.ID, c.Param("id"), c.Param("snapshotId"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"snapshot": snapshot, "project": json.RawMessage(snapshot.PayloadJSON)})
})
r.POST("/canvas-projects/:id/history/:snapshotId/restore", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
policy, available := loadRuntimePolicy(c, svc)
if !available || !enforceRateLimit(c, "canvas-write:"+user.ID, policy.Request.CanvasWritePerMinute, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1024)
var req struct {
Revision *int64 `json:"revision"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, service.BadAuthRequest("恢复请求格式错误"))
return
}
project, err := svc.RestoreCanvasHistory(user.ID, c.Param("id"), c.Param("snapshotId"), req.Revision)
if err != nil {
failService(c, err)
return
}
slog.Info("canvas_restore", "request_id", RequestID(c), "trace_id", TraceID(c), "actor", user.ID, "canvas", c.Param("id"), "snapshot", c.Param("snapshotId"), "base_revision", *req.Revision, "revision", project.Revision)
ok(c, gin.H{"project": project})
})
r.PUT("/canvas-projects/:id", func(c *gin.Context) {
r.PATCH("/canvas-projects/:id/operations", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
@@ -620,58 +567,17 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20)
var req struct {
Project json.RawMessage `json:"project"`
RepairMissingResources bool `json:"repairMissingResources"`
}
var req service.CanvasOperationsRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
fail(c, http.StatusBadRequest, service.BadAuthRequest("画布操作请求格式错误"))
return
}
var identity struct {
ID string `json:"id"`
}
if json.Unmarshal(req.Project, &identity) != nil || identity.ID != c.Param("id") {
fail(c, http.StatusBadRequest, service.BadAuthRequest("画布 ID 与请求路径不一致"))
return
}
var audit struct {
Revision *int64 `json:"revision"`
Nodes []json.RawMessage `json:"nodes"`
Connections []json.RawMessage `json:"connections"`
}
_ = json.Unmarshal(req.Project, &audit)
baseRevision := int64(-1)
if audit.Revision != nil {
baseRevision = *audit.Revision
}
var project service.UserDataSummary
if req.RepairMissingResources {
project, err = svc.RepairUserCanvasProject(user.ID, req.Project)
} else {
project, err = svc.UpsertUserCanvasProject(user.ID, req.Project)
}
defer func() {
nodesBefore, nodesAfter := -1, len(audit.Nodes)
if project.SaveAudit != nil {
nodesBefore, nodesAfter = project.SaveAudit.NodesBefore, project.SaveAudit.NodesAfter
}
// Metadata only: never log prompts, media URLs, cookies, or the canvas payload.
// 自动保存非常频繁:常规保存只在 debug 输出;节点减少(可能丢数据)或保存失败才提升级别。
level := slog.LevelDebug
switch {
case c.Writer.Status() >= http.StatusBadRequest:
level = slog.LevelWarn
case nodesBefore >= 0 && nodesAfter < nodesBefore:
level = slog.LevelInfo
}
slog.Log(c.Request.Context(), level, "canvas_save", "request_id", RequestID(c), "trace_id", TraceID(c), "actor", user.ID, "canvas", identity.ID, "base_revision", baseRevision, "revision", project.Revision, "nodes_before", nodesBefore, "nodes_after", nodesAfter, "connections", len(audit.Connections), "status", c.Writer.Status())
}()
project, err := svc.ApplyUserCanvasOperations(user.ID, c.Param("id"), req)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"project": project})
ok(c, gin.H{"project": project, "mutationId": req.MutationID})
})
r.DELETE("/canvas-projects/:id", func(c *gin.Context) {
user, err := currentUser(c, svc)

View File

@@ -0,0 +1,18 @@
package model
import "time"
// CanvasMutationReceipt makes a client retry observable without making the
// canvas document carry an ever-growing list of request IDs. The receipt is a
// recovery hint; the mutation itself remains idempotent because applying the
// same desired values is a no-op.
type CanvasMutationReceipt struct {
ID string `json:"id" gorm:"primaryKey;size:80"`
UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_canvas_mutation_receipt_identity,priority:1"`
CanvasID string `json:"canvasId" gorm:"index;size:80;uniqueIndex:idx_canvas_mutation_receipt_identity,priority:2"`
MutationID string `json:"mutationId" gorm:"size:160;uniqueIndex:idx_canvas_mutation_receipt_identity,priority:3"`
PayloadDigest string `json:"payloadDigest" gorm:"size:64;index"`
ResultJSON string `json:"resultJson" gorm:"type:text"`
Revision int64 `json:"revision"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
}

View File

@@ -0,0 +1,47 @@
package repository
import (
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"yingce/backend/internal/model"
)
// LockCanvasMutation serializes all operations for an existing canvas, including
// deletion. On SQLite this first write also avoids a deferred read transaction
// trying to upgrade its stale snapshot to a write lock.
func (r *Repository) LockCanvasMutation(userID, canvasID string) error {
return r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", canvasID, userID).
UpdateColumn("revision", gorm.Expr("revision")).Error
}
func (r *Repository) CanvasMutationReceiptForUser(userID, canvasID, mutationID string) (*model.CanvasMutationReceipt, error) {
var receipt model.CanvasMutationReceipt
err := r.db.First(&receipt, "user_id = ? AND canvas_id = ? AND mutation_id = ?", userID, canvasID, mutationID).Error
return &receipt, err
}
func (r *Repository) TryCreateCanvasMutationReceipt(receipt *model.CanvasMutationReceipt) (bool, error) {
if receipt.CreatedAt.IsZero() {
receipt.CreatedAt = time.Now().UTC()
}
result := r.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}, {Name: "canvas_id"}, {Name: "mutation_id"}}, DoNothing: true}).Create(receipt)
return result.RowsAffected == 1, result.Error
}
func (r *Repository) FinalizeCanvasMutationReceipt(receipt *model.CanvasMutationReceipt) error {
if receipt.ResultJSON == "" {
return errors.New("canvas mutation receipt requires a committed result")
}
result := r.db.Model(&model.CanvasMutationReceipt{}).Where("id = ? AND payload_digest = ?", receipt.ID, receipt.PayloadDigest).
Updates(map[string]any{"result_json": receipt.ResultJSON, "revision": receipt.Revision})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errors.New("canvas mutation receipt was not finalized")
}
return nil
}

View File

@@ -55,6 +55,14 @@ func (r *Repository) WithContext(ctx context.Context) *Repository {
return &Repository{db: r.db.WithContext(ctx)}
}
// Transaction exposes a repository scoped to one database transaction. Domain
// services use it when a canvas mutation and its receipt must commit together.
func (r *Repository) Transaction(fn func(*Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(New(tx))
})
}
func (r *Repository) Dialect() string {
return r.db.Dialector.Name()
}
@@ -95,6 +103,26 @@ func (r *Repository) UserStorageUsage(userID string) (UserStorageUsage, error) {
return usage, err
}
// UserStructuredStorageUsage reads only the counters and payload bytes used by
// asset/canvas structured-data quotas. Keeping task and API-log accounting out of
// this path avoids scanning unrelated large history rows on every canvas write.
func (r *Repository) UserStructuredStorageUsage(userID string) (UserStorageUsage, error) {
var usage UserStorageUsage
query := `
SELECT
(SELECT COUNT(*) FROM assets WHERE user_id = ?) AS asset_count,
(SELECT COALESCE(SUM(length(CAST(COALESCE(payload_json, '') AS BLOB))), 0) FROM assets WHERE user_id = ?) AS asset_bytes,
(SELECT COUNT(*) FROM canvas_projects WHERE user_id = ?) AS canvas_count,
(SELECT COALESCE(SUM(length(CAST(COALESCE(payload_json, '') AS BLOB))), 0) FROM canvas_projects WHERE user_id = ?) AS canvas_bytes
`
if r.Dialect() == "postgres" {
query = strings.ReplaceAll(query, "length(CAST(COALESCE(", "octet_length(COALESCE(")
query = strings.ReplaceAll(query, ", '') AS BLOB))", ", ''))")
}
err := r.db.Raw(query, userID, userID, userID, userID).Scan(&usage).Error
return usage, err
}
// Create 是低层兼容入口;业务写路径应优先使用带领域约束的显式方法。
func (r *Repository) Create(value any) error {
return r.db.Create(value).Error

View File

@@ -0,0 +1,89 @@
package repository
import (
"errors"
"path/filepath"
"testing"
"yingce/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestUserStructuredStorageUsage(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "quota.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
// History tables are deliberately absent: canvas quota must not read them.
if err := db.AutoMigrate(&model.Asset{}, &model.CanvasProject{}); err != nil {
t.Fatal(err)
}
repo := New(db)
assetPayload := `{"title":"素材 🎬"}`
canvasPayload := `{"title":"画布","nodes":[]}`
assets := []model.Asset{
{ID: "asset", UserID: "owner", PayloadJSON: assetPayload},
{ID: "empty", UserID: "owner"},
{ID: "null", UserID: "owner"},
{ID: "foreign-asset", UserID: "other", PayloadJSON: assetPayload},
}
canvases := []model.CanvasProject{
{ID: "canvas", UserID: "owner", PayloadJSON: canvasPayload},
{ID: "foreign-canvas", UserID: "other", PayloadJSON: canvasPayload},
}
if err := db.Create(&assets).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.Asset{}).Where("id = ?", "null").UpdateColumn("payload_json", nil).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&canvases).Error; err != nil {
t.Fatal(err)
}
want := UserStorageUsage{AssetCount: 3, AssetBytes: int64(len(assetPayload)), CanvasCount: 1, CanvasBytes: int64(len(canvasPayload))}
assertUsage := func(repo *Repository, userID string, expected UserStorageUsage) {
t.Helper()
got, err := repo.UserStructuredStorageUsage(userID)
if err != nil {
t.Fatal(err)
}
if got != expected {
t.Fatalf("usage for %s = %+v, want %+v", userID, got, expected)
}
}
assertUsage(repo, "owner", want)
assertUsage(repo, "missing", UserStorageUsage{})
assertUsage(repo, "other", UserStorageUsage{AssetCount: 1, AssetBytes: int64(len(assetPayload)), CanvasCount: 1, CanvasBytes: int64(len(canvasPayload))})
rollback := errors.New("rollback quota probe")
err = repo.Transaction(func(tx *Repository) error {
project := model.CanvasProject{ID: "uncommitted", UserID: "owner", PayloadJSON: canvasPayload}
if err := tx.UpsertCanvasProject(&project); err != nil {
return err
}
inside := want
inside.CanvasCount++
inside.CanvasBytes += int64(len(canvasPayload))
assertUsage(tx, "owner", inside)
return rollback
})
if !errors.Is(err, rollback) {
t.Fatalf("transaction error = %v", err)
}
assertUsage(repo, "owner", want)
if err := db.Migrator().DropTable(&model.Asset{}); err != nil {
t.Fatal(err)
}
if _, err := repo.UserStructuredStorageUsage("owner"); err == nil {
t.Fatal("database error must not become zero usage")
}
}

View File

@@ -89,6 +89,7 @@ type (
BulkDisableUsersResult = app.BulkDisableUsersResult
CanvasLibraryPage = app.CanvasLibraryPage
CanvasLibrarySummary = app.CanvasLibrarySummary
CanvasOperationsRequest = app.CanvasOperationsRequest
CanvasHistoryList = app.CanvasHistoryList
CanvasShareRequest = app.CanvasShareRequest
CanvasShareStatus = app.CanvasShareStatus