feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 为指定用户的画布保存计划中的媒体节点及引用连线。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -46,7 +46,7 @@ func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, pur
|
||||
// 删除语义(产品约定,勿收紧):
|
||||
// - purge=true 是用户在素材库确认后的「彻底删除」(单个 DELETE、批量删除、清空回收站),
|
||||
// 不因画布、任务、项目或画布历史快照仍引用而拒绝;旧引用随之失效,画布保存时由
|
||||
// repairMissingResources 修复断链。
|
||||
// 由统一 operations 提交前的资源完整性校验修复断链。
|
||||
// - purge=false 是内部普通删除:回收站(archived)素材同样直接删除,其余素材保留引用保护。
|
||||
// - 无论哪种,被其他素材复用的资源与物理对象都保留,物理删除一律经 Outbox 异步执行。
|
||||
deleteReferencedResources := purge
|
||||
|
||||
Reference in New Issue
Block a user