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

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