Files
ttstd-infinite-canvas/backend/internal/canvas/user_data.go

636 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}