package canvas import ( "encoding/json" "errors" "net/http" "strings" "time" "unicode/utf8" "yingce/backend/internal/kernel" "yingce/backend/internal/model" "yingce/backend/internal/repository" "gorm.io/gorm" ) type AssetsSyncRequest struct { Assets []json.RawMessage `json:"assets"` } type UserDataSummary struct { ID string `json:"id"` FolderID string `json:"folderId,omitempty"` Kind string `json:"kind,omitempty"` Category string `json:"category,omitempty"` Status string `json:"status,omitempty"` Title string `json:"title"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Revision int64 `json:"revision,omitempty"` SaveAudit *CanvasSaveAudit `json:"-"` } type CanvasSaveAudit struct { NodesBefore int NodesAfter int } func canvasNodeCount(payload string) int { var document struct { Nodes []json.RawMessage `json:"nodes"` } _ = json.Unmarshal([]byte(payload), &document) return len(document.Nodes) } type UserDataSnapshot struct { Assets []json.RawMessage `json:"assets"` Projects []json.RawMessage `json:"projects"` } func (s *Service) UserDataSnapshot(userID string) (UserDataSnapshot, error) { assets, err := s.UserAssets(userID) if err != nil { return UserDataSnapshot{}, err } projects, err := s.UserCanvasProjects(userID) if err != nil { return UserDataSnapshot{}, err } return UserDataSnapshot{Assets: assets, Projects: projects}, nil } func (s *Service) UserAssetSummaries(userID string) ([]UserDataSummary, error) { assets, err := s.repo.AssetSummaries(userID) if err != nil { return nil, err } result := make([]UserDataSummary, 0, len(assets)) for _, asset := range assets { result = append(result, UserDataSummary{ID: asset.ID, FolderID: asset.FolderID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}) } return result, nil } func (s *Service) UserAsset(userID string, id string) (json.RawMessage, error) { asset, err := s.repo.AssetForUser(userID, id) if err != nil { return nil, err } return ClientAssetPayload(*asset), nil } func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataSummary, error) { asset, err := AssetFromJSON(userID, raw) if err != nil { return UserDataSummary{}, err } err = s.host.WithStorageLock(func() error { if asset.FolderID != "" { if _, err := s.repo.AssetFolderForUser(userID, asset.FolderID); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return kernel.BadAuthRequest("素材分类不存在") } return err } } existing, existingErr := s.repo.AssetForUser(userID, asset.ID) if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) { return existingErr } if existing != nil && existing.PayloadJSON != asset.PayloadJSON { if err := s.ValidateAssetCanvasReferences(userID, asset); err != nil { return err } } existingBytes := int64(0) if existing != nil { existingBytes = int64(len([]byte(existing.PayloadJSON))) } if err := s.host.StructuredQuota(userID, "asset", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(raw))-existingBytes); err != nil { return err } if err := s.repo.UpsertAsset(&asset); err != nil { return err } if existingErr != nil { s.host.RecordActivity(userID, "asset", 1) } return nil }) if err != nil { return UserDataSummary{}, err } return UserDataSummary{ID: asset.ID, FolderID: asset.FolderID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}, nil } func (s *Service) DeleteUserAsset(userID string, id string) error { return s.host.WithStorageLock(func() error { return s.host.DeleteUserAssetWithResources(userID, id, false) }) } func (s *Service) PurgeUserAsset(userID string, id string) error { return s.PurgeUserAssets(userID, []string{id}) } func (s *Service) PurgeUserAssets(userID string, ids []string) error { return s.host.WithStorageLock(func() error { return s.host.PurgeUserAssetsWithResources(userID, ids) }) } func (s *Service) UserAssets(userID string) ([]json.RawMessage, error) { assets, err := s.repo.Assets(userID) if err != nil { return nil, err } result := make([]json.RawMessage, 0, len(assets)) for _, asset := range assets { if payload := ClientAssetPayload(asset); len(payload) > 0 { result = append(result, payload) } } return result, nil } func (s *Service) ReplaceUserAssets(userID string, req AssetsSyncRequest) ([]json.RawMessage, error) { assets := make([]model.Asset, 0, len(req.Assets)) var totalBytes int64 for _, raw := range req.Assets { item, err := AssetFromJSON(userID, raw) if err != nil { return nil, err } assets = append(assets, item) totalBytes += int64(len(raw)) } err := s.host.WithStorageLock(func() error { if err := s.ValidateAssetReplacementCanvasReferences(userID, assets); err != nil { return err } if err := s.host.StructuredReplacementQuota(userID, "asset", len(assets), totalBytes); err != nil { return err } if err := s.repo.ReplaceAssets(userID, assets); err != nil { return err } if len(assets) > 0 { s.host.RecordActivity(userID, "asset", len(assets)) } return nil }) if err != nil { return nil, err } return s.UserAssets(userID) } func (s *Service) UserCanvasProjects(userID string) ([]json.RawMessage, error) { projects, err := s.repo.CanvasProjects(userID) if err != nil { return nil, err } result := make([]json.RawMessage, 0, len(projects)) for _, project := range projects { if strings.TrimSpace(project.PayloadJSON) != "" { payload, err := canvasProjectPayload(project) if err != nil { return nil, err } result = append(result, payload) } } return result, nil } func (s *Service) UserCanvasProjectSummaries(userID string) ([]UserDataSummary, error) { projects, err := s.repo.CanvasProjectSummaries(userID) if err != nil { return nil, err } result := make([]UserDataSummary, 0, len(projects)) for _, project := range projects { result = append(result, UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, Revision: project.Revision}) } return result, nil } func (s *Service) UserCanvasProjectMetadata(userID string, id string) (*model.CanvasProject, error) { return s.repo.CanvasProjectMetadataForUser(userID, id) } func (s *Service) UserCanvasProject(userID string, id string) (json.RawMessage, error) { project, err := s.repo.CanvasProjectForUser(userID, id) if err != nil { return nil, err } return canvasProjectPayload(*project) } func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (UserDataSummary, error) { 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"` } if err := json.Unmarshal(raw, &version); err != nil { return UserDataSummary{}, kernel.BadAuthRequest("画布版本格式错误") } if version.Revision == nil { return UserDataSummary{}, kernel.NewAppError(http.StatusPreconditionRequired, "缺少画布版本,请保留本地草稿后重新加载画布") } if *version.Revision < 0 || *version.Revision >= 9007199254740991 { return UserDataSummary{}, kernel.BadAuthRequest("画布版本无效") } project, err := canvasProjectFromJSON(userID, raw) if err != nil { return UserDataSummary{}, err } var audit CanvasSaveAudit err = s.host.WithStorageLock(func() error { if err := s.ValidateCanvasMediaAssets(userID, raw); err != nil { return err } existing, existingErr := s.repo.CanvasProjectForUser(userID, project.ID) if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) { return existingErr } existingBytes := int64(0) project.Revision = *version.Revision project.UpdatedAt = time.Now().UTC() if existing != nil { existingBytes = int64(len([]byte(existing.PayloadJSON))) project.CreatedAt = existing.CreatedAt } if (existing == nil && project.Revision != 0) || (existing != nil && project.Revision != existing.Revision) { return canvasRevisionConflict() } var payload map[string]json.RawMessage if err := json.Unmarshal(raw, &payload); err != nil { return err } delete(payload, "revision") delete(payload, "remoteContentHash") payload["viewport"] = json.RawMessage(`{"x":0,"y":0,"k":1}`) if existing != nil { var previous map[string]json.RawMessage if json.Unmarshal([]byte(existing.PayloadJSON), &previous) == nil && previous["viewport"] != nil { payload["viewport"] = previous["viewport"] } } payload["createdAt"], _ = json.Marshal(project.CreatedAt) payload["updatedAt"], _ = json.Marshal(project.UpdatedAt) cleaned, err := json.Marshal(payload) if err != nil { return err } project.PayloadJSON = string(cleaned) if err := s.host.StructuredQuota(userID, "canvas", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(cleaned))-existingBytes); err != nil { return err } if err := SaveDocumentWithHistory(s.repo, existing, &project, reason); err != nil { if errors.Is(err, repository.ErrCanvasRevisionConflict) { return canvasRevisionConflict() } var missing *repository.CanvasHistoryResourceMissingError if errors.As(err, &missing) { return canvasResourcesMissingError(missing.References) } return err } audit.NodesAfter = canvasNodeCount(project.PayloadJSON) if existing != nil { audit.NodesBefore = canvasNodeCount(existing.PayloadJSON) } if existingErr != nil || existing.PayloadJSON != project.PayloadJSON || existing.Title != project.Title { s.host.RecordActivity(userID, "canvas", 1) } return nil }) if err != nil { return UserDataSummary{}, err } return UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, Revision: project.Revision, SaveAudit: &audit}, nil } func (s *Service) DeleteUserCanvasProject(userID string, id string) error { return s.repo.DeleteCanvasProject(userID, id) } func AssetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) { if err := ValidateSyncedPayload(raw, "素材"); err != nil { return model.Asset{}, err } var payload struct { ID string `json:"id"` FolderID string `json:"folderId"` Kind string `json:"kind"` Category string `json:"category"` Status string `json:"status"` PrimaryVersionID string `json:"primaryVersionId"` Title string `json:"title"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } if err := json.Unmarshal(raw, &payload); err != nil { return model.Asset{}, kernel.BadAuthRequest("素材数据格式错误") } now := time.Now() createdAt := parseClientTime(payload.CreatedAt, now) updatedAt := parseClientTime(payload.UpdatedAt, createdAt) id := strings.TrimSpace(payload.ID) if id == "" { id = kernel.NewID() } if utf8.RuneCountInString(id) > model.AssetIDMaxLength { return model.Asset{}, kernel.BadAuthRequest("素材 ID 不能超过 80 个字符") } primaryVersionID := strings.TrimSpace(payload.PrimaryVersionID) if utf8.RuneCountInString(primaryVersionID) > 36 { return model.Asset{}, kernel.BadAuthRequest("素材主版本 ID 不能超过 36 个字符") } if err := validateUserAssetDocument(raw); err != nil { return model.Asset{}, err } category := model.NormalizeAssetCategory(model.AssetCategory(payload.Category), payload.Kind) status := model.AssetVersionStatus(strings.TrimSpace(payload.Status)) if status == "" { status = model.AssetVersionStatusConfirmed } return model.Asset{ ID: id, UserID: userID, FolderID: strings.TrimSpace(payload.FolderID), Kind: strings.TrimSpace(payload.Kind), Category: category, Status: status, PrimaryVersionID: primaryVersionID, Title: strings.TrimSpace(payload.Title), PayloadJSON: string(raw), CreatedAt: createdAt, UpdatedAt: updatedAt, }, nil } func canvasProjectFromJSON(userID string, raw json.RawMessage) (model.CanvasProject, error) { 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"` Title string `json:"title"` ProjectID string `json:"projectId"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } if err := json.Unmarshal(raw, &payload); err != nil { return model.CanvasProject{}, kernel.BadAuthRequest("画布数据格式错误") } now := time.Now() createdAt := parseClientTime(payload.CreatedAt, now) updatedAt := parseClientTime(payload.UpdatedAt, createdAt) id := strings.TrimSpace(payload.ID) if id == "" { id = kernel.NewID() } return model.CanvasProject{ ID: id, UserID: userID, ProjectID: strings.TrimSpace(payload.ProjectID), Title: strings.TrimSpace(payload.Title), PayloadJSON: string(raw), CreatedAt: createdAt, UpdatedAt: updatedAt, }, nil } func ValidateSyncedPayload(raw json.RawMessage, label string) error { if len(raw) > 4<<20 { return kernel.BadAuthRequest(label + "数据超过 4MB,请先把媒体文件保存到资源存储") } var payload interface{} if err := json.Unmarshal(raw, &payload); err == nil && ContainsInlineMediaDataURL(payload) { return kernel.BadAuthRequest(label + "数据包含内嵌媒体,请先上传到资源存储") } return nil } // 同步数据只禁止作为字段值存在的媒体 Data URL;提示词和上游错误文案可能合法提到相同字符串。 func ContainsInlineMediaDataURL(value interface{}) bool { switch item := value.(type) { case string: text := strings.ToLower(strings.TrimSpace(item)) return strings.HasPrefix(text, "data:image/") || strings.HasPrefix(text, "data:video/") || strings.HasPrefix(text, "data:audio/") case []interface{}: for _, child := range item { if ContainsInlineMediaDataURL(child) { return true } } case map[string]interface{}: for _, child := range item { if ContainsInlineMediaDataURL(child) { return true } } } return false } func parseClientTime(value string, fallback time.Time) time.Time { value = strings.TrimSpace(value) if value == "" { return fallback } if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil { return parsed } return fallback }